commit 43c80237ece7abe55d2bd68c857b381cae172f64 Author: CAtlas0784 Date: Wed Oct 7 21:42:41 2026 +0700 Initial commit: Escape penacony standalone Android Native App diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e3bdbed --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +*.iml +.gradle +/local.properties +/.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +/app/build diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac74094 --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# Escape penacony standalone 📱✨ +## ppSR Scene & Map Teleporter - Native Android Application + +แอปพลิเคชัน Android Standalone สำหรับจัดการและเทเลพอร์ตแผนที่ในเซิร์ฟเวอร์จำลอง **ppSR (Project Star Rail)** บนระบบปฏิบัติการ Android โดยตรง พร้อมระบบสัมผัส (Touch UI) ที่ทันสมัย และระบบอัปเดตอัตโนมัติในตัว (In-App Auto-Updater) + +- **ชื่อแอปพลิเคชันเต็ม (Full Name)**: `Escape penacony standalone` +- **ชื่อแอปพลิเคชันที่แสดงบนอุปกรณ์ (Display Name)**: `Escape penacony` +- **Application ID / Package**: `com.catlas.escapepenacony` +- **สถาปัตยกรรม**: Native Android (Kotlin + Jetpack Compose + Material Design 3) + +--- + +## 🌟 ฟีเจอร์หลัก (Key Features) + +1. **Native Touch UI (Jetpack Compose & Material Design 3)** + - อินเทอร์เฟซมืด (Dark Cyberpunk Theme) ดีไซน์พิเศษ + - รองรับแถบเลือกดวงดาว (Planet Tabs) แบบเลื่อนแนวนอน และระบบค้นหาฉาก/พิกัดแบบเรียลไทม์ +2. **ฐานข้อมูลแผนที่ครบถ้วน (Full Data Parity)** + - ครอบคลุม **8 ดวงดาว 85 ฉาก/แผนที่** จาก `scenes.json` + - แยกระบบหมวดหมู่ด้วยแท็กสีชัดเจน: + - ⚔️ **Combat Ready**: ฉากที่รองรับเสาต่อสู้ Calyx ดั้งเดิมในไคลเอนต์ Official + - 🗺️ **Safe Scenery**: ฉากสำหรับเดินชมวิว เสา Calyx ซ่อนใต้ดินที่ระดับความลึก Y=-999999 ปลอดภัยจากการแครช + - 🧪 **4.7 Beta Exclusive**: ฉากพิเศษสำหรับไคลเอนต์เบต้า 4.7 +3. **ระบบจัดการไฟล์ผ่าน Android Storage Access Framework (SAF)** + - เลือกระบุไฟล์ `persistent.json` ผ่าน Document Picker รองรับ Android 10, 11, 12, 13, 14, และ 15+ + - กดปุ่ม **Teleport** เพียงครั้งเดียวเพื่ออัปเดตพิกัดลงไฟล์ `persistent.json` ทันที พร้อมระบบสำรองไฟล์อัตโนมัติ + - มีปุ่ม **Copy JSON** สำหรับคัดลอก JSON พิกัดไปใช้งานต่อได้ทันที +4. **Custom Coordinates Input** + - ปุ่มลอย (+) สำหรับกรอก Plane ID, X, Y, Z, และ Map Layer ที่กำหนดเองได้อิสระ +5. **In-App Auto-Updater & OTA Hot Sync** + - **ตรวจจับเวอร์ชันใหม่ผ่าน GitHub Releases API**: เปรียบเทียบ `tag_name` กับเวอร์ชันในเครื่อง ดาวน์โหลด `.apk` และเรียกตัวติดตั้งระบบอัตโนมัติผ่าน `FileProvider` + - **OTA Map Update**: ปุ่มซิงค์ดาวน์โหลด `scenes.json` ตัวล่าสุดจาก GitHub Raw ได้ทันทีโดยไม่ต้องลง APK ใหม่ +6. **รองรับ 3 ภาษา (Trilingual Support)** + - สลับภาษาระหว่าง **English (EN)**, **ภาษาไทย (TH)**, และ **简体中文 (ZH)** ได้ทันทีผ่านปุ่มบนแถบเมนูด้านบน + +--- + +## 🏗️ โครงสร้างโปรเจกต์ (Project Structure) + +```text +Android APK/ +├── app/ +│ ├── build.gradle.kts +│ ├── proguard-rules.pro +│ └── src/main/ +│ ├── AndroidManifest.xml +│ ├── assets/ +│ │ └── scenes.json # ฐานข้อมูลแผนที่ 8 โลก 85 แมพ +│ ├── java/com/catlas/escapepenacony/ +│ │ ├── MainActivity.kt # Activity หลักและ SAF Document Picker +│ │ ├── model/ +│ │ │ ├── SceneModels.kt # โมเดลข้อมูล Planet & Scene & Language +│ │ │ └── PersistentConfig.kt # โครงสร้าง JSON ของ persistent.json +│ │ ├── data/ +│ │ │ ├── SceneRepository.kt # โหลดและบันทึก scenes.json (รองรับ OTA) +│ │ │ ├── StorageManager.kt # อ่าน/เขียน persistent.json ผ่าน SAF +│ │ │ └── UpdateManager.kt # GitHub Releases API & APK Self-Updater +│ │ └── ui/ +│ │ ├── MainScreen.kt # หน้าจอหลัก Compose +│ │ ├── Components.kt # SceneCard, Badges, Dialogs +│ │ └── theme/ # Color, Theme, Type +│ └── res/ +│ ├── values/ # strings.xml, colors.xml, themes.xml +│ └── xml/file_paths.xml # FileProvider path สำหรับติดตั้ง APK +├── gradle/ +│ ├── libs.versions.toml # Version Catalog +│ └── wrapper/ # Gradle Wrapper 8.9 +├── build.gradle.kts +├── settings.gradle.kts +└── gradlew / gradlew.bat +``` + +--- + +## 🛠️ วิธีการเปิดและคอมไพล์โปรเจกต์ (How to Build) + +### 1. เปิดผ่าน Android Studio (แนะนำ) +1. เปิดโปรแกรม **Android Studio** +2. เลือก **Open** แล้วชี้ไปที่โฟลเดอร์ `Android APK` +3. รอ Android Studio ซิงค์ Gradle ให้เสร็จสิ้น +4. เลือกเมนู **Build** > **Build Bundle(s) / APK(s)** > **Build APK(s)** หรือกด Run ลงบนโทรศัพท์ Android / Emulator + +### 2. คอมไพล์ผ่าน Command Line (CLI) +เปิด Terminal หรือ PowerShell ที่โฟลเดอร์ `Android APK`: + +```bash +# คอมไพล์ Debug APK +./gradlew assembleDebug + +# คอมไพล์ Release APK +./gradlew assembleRelease +``` + +ไฟล์ APK ที่สร้างเสร็จจะอยู่ที่: +`app/build/outputs/apk/debug/app-debug.apk` diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..f3969df --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,56 @@ +plugins { + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "com.catlas.escapepenacony" + compileSdk = 35 + + defaultConfig { + applicationId = "com.catlas.escapepenacony" + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "v1.0.0" + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + isMinifyEnabled = false + 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(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) + + debugImplementation(libs.androidx.ui.tooling) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..d04b24c --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# Escape penacony ProGuard Rules +-keepattributes *Annotation* diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..77e9b62 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/assets/scenes.json b/app/src/main/assets/scenes.json new file mode 100644 index 0000000..32a226d --- /dev/null +++ b/app/src/main/assets/scenes.json @@ -0,0 +1,2130 @@ +{ + "version": "4.6.5X-MASTER-LOCALIZED", + "updated_at": "2026-10-04", + "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": "Fleeting Joy", + "name_th": "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", + "desc_th": "บาร์สังสรรค์ที่ความโศกเศร้าและความสุขผสมผสานกัน", + "desc_zh": "悲欢交织的瞬息酒吧", + "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/catlas/escapepenacony/MainActivity.kt b/app/src/main/java/com/catlas/escapepenacony/MainActivity.kt new file mode 100644 index 0000000..4e0a25b --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/MainActivity.kt @@ -0,0 +1,52 @@ +package com.catlas.escapepenacony + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.* +import com.catlas.escapepenacony.data.StorageManager +import com.catlas.escapepenacony.ui.MainScreen +import com.catlas.escapepenacony.ui.theme.EscapePenaconyTheme + +class MainActivity : ComponentActivity() { + + private var currentPersistentUri by mutableStateOf(null) + + // Storage Access Framework Document Picker for persistent.json + private val openDocumentLauncher = registerForActivityResult( + ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + if (uri != null) { + try { + contentResolver.takePersistableUriPermission( + uri, + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + ) + } catch (_: Exception) {} + + StorageManager.savePersistentUri(this, uri) + currentPersistentUri = uri + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Restore previously selected persistent.json if available + currentPersistentUri = StorageManager.getPersistentUri(this) + + setContent { + EscapePenaconyTheme { + MainScreen( + currentPersistentUri = currentPersistentUri, + onPickPersistentFile = { + openDocumentLauncher.launch(arrayOf("application/json", "*/*")) + } + ) + } + } + } +} diff --git a/app/src/main/java/com/catlas/escapepenacony/data/SceneRepository.kt b/app/src/main/java/com/catlas/escapepenacony/data/SceneRepository.kt new file mode 100644 index 0000000..c224620 --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/data/SceneRepository.kt @@ -0,0 +1,131 @@ +package com.catlas.escapepenacony.data + +import android.content.Context +import com.catlas.escapepenacony.model.PlanetItem +import com.catlas.escapepenacony.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 jsonContent = if (cachedFile.exists()) { + cachedFile.readText() + } else { + context.assets.open("scenes.json").bufferedReader().use { it.readText() } + } + parseJson(jsonContent) + } + + suspend fun saveUpdatedScenesJson(context: Context, jsonString: String): Boolean = withContext(Dispatchers.IO) { + try { + // Validate JSON first + 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) + + 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 + ) + ) + } + } + + planets.add( + PlanetItem( + id = pId, + nameEn = pNameEn, + nameTh = pNameTh, + nameZh = pNameZh, + scenes = scenes + ) + ) + } + + return planets + } +} diff --git a/app/src/main/java/com/catlas/escapepenacony/data/StorageManager.kt b/app/src/main/java/com/catlas/escapepenacony/data/StorageManager.kt new file mode 100644 index 0000000..b548c0a --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/data/StorageManager.kt @@ -0,0 +1,93 @@ +package com.catlas.escapepenacony.data + +import android.content.Context +import android.net.Uri +import com.catlas.escapepenacony.model.PersistentHelper +import com.catlas.escapepenacony.model.SceneConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream + +object StorageManager { + + private const val PREFS_NAME = "escape_penacony_prefs" + private const val KEY_PERSISTENT_URI = "persistent_uri" + private const val KEY_SELECTED_LANG = "selected_language" + + fun savePersistentUri(context: Context, uri: Uri) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_PERSISTENT_URI, uri.toString()) + .apply() + } + + fun getPersistentUri(context: Context): Uri? { + val uriStr = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_PERSISTENT_URI, null) ?: return null + return try { + Uri.parse(uriStr) + } catch (e: Exception) { + null + } + } + + fun saveLanguage(context: Context, langCode: String) { + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_SELECTED_LANG, langCode) + .apply() + } + + fun getSavedLanguage(context: Context): String { + return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(KEY_SELECTED_LANG, "en") ?: "en" + } + + suspend fun readCurrentConfig(context: Context, uri: Uri): SceneConfig? = withContext(Dispatchers.IO) { + try { + val content = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } + ?: return@withContext null + val root = JSONObject(content) + val sceneObj = root.optJSONObject("scene") ?: return@withContext null + SceneConfig.fromJsonObject(sceneObj) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + suspend fun applySceneConfig(context: Context, uri: Uri, newConfig: SceneConfig): Result = withContext(Dispatchers.IO) { + try { + // Read original content + val originalContent = context.contentResolver.openInputStream(uri)?.bufferedReader()?.use { it.readText() } ?: "{}" + + // Backup copy in app cache + try { + val backupFile = File(context.cacheDir, "persistent_backup.json") + backupFile.writeText(originalContent) + } catch (_: Exception) {} + + // Generate updated JSON + val updatedJson = PersistentHelper.updateJsonString(originalContent, newConfig) + + // Overwrite target file via SAF + context.contentResolver.openOutputStream(uri, "wt")?.use { output -> + output.write(updatedJson.toByteArray(Charsets.UTF_8)) + output.flush() + } ?: return@withContext Result.failure(Exception("Failed to open output stream")) + + Result.success(Unit) + } catch (e: Exception) { + e.printStackTrace() + Result.failure(e) + } + } + + fun exportTemplateJson(context: Context, newConfig: SceneConfig): String { + val root = JSONObject() + root.put("scene", newConfig.toJsonObject()) + return root.toString(2) + } +} diff --git a/app/src/main/java/com/catlas/escapepenacony/data/UpdateManager.kt b/app/src/main/java/com/catlas/escapepenacony/data/UpdateManager.kt new file mode 100644 index 0000000..595d721 --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/data/UpdateManager.kt @@ -0,0 +1,183 @@ +package com.catlas.escapepenacony.data + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.Settings +import androidx.core.content.FileProvider +import com.catlas.escapepenacony.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection +import java.net.URL + +data class ReleaseInfo( + val tagName: String, + val releaseTitle: String, + val changelog: String, + val apkDownloadUrl: String, + val isNewer: Boolean +) + +object UpdateManager { + + private const val GITHUB_RELEASES_API = + "https://api.github.com/repos/CAtlas0784/Escape-penacony/releases/latest" + + private const val GITHUB_RAW_SCENES_URL = + "https://raw.githubusercontent.com/CAtlas0784/Escape-penacony/main/scenes.json" + + suspend fun checkForAppUpdate(currentVersion: String = BuildConfig.VERSION_NAME): Result = + withContext(Dispatchers.IO) { + try { + val url = URL(GITHUB_RELEASES_API) + val conn = url.openConnection() as HttpURLConnection + conn.connectTimeout = 8000 + conn.readTimeout = 8000 + conn.setRequestProperty("Accept", "application/vnd.github.v3+json") + conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App") + + if (conn.responseCode == 200) { + val body = conn.inputStream.bufferedReader().use { it.readText() } + val json = JSONObject(body) + val tagName = json.getString("tag_name") + val title = json.optString("name", tagName) + val changelog = json.optString("body", "No changelog provided.") + + var downloadUrl = "" + val assets = json.optJSONArray("assets") + if (assets != null) { + for (i in 0 until assets.length()) { + val asset = assets.getJSONObject(i) + val name = asset.optString("name", "") + if (name.endsWith(".apk", ignoreCase = true)) { + downloadUrl = asset.getString("browser_download_url") + break + } + } + } + + val isNewer = isNewerVersion(currentVersion, tagName) + Result.success( + ReleaseInfo( + tagName = tagName, + releaseTitle = title, + changelog = changelog, + apkDownloadUrl = downloadUrl, + isNewer = isNewer + ) + ) + } else { + Result.success(null) + } + } catch (e: Exception) { + e.printStackTrace() + Result.failure(e) + } + } + + suspend fun downloadApk( + context: Context, + downloadUrl: String, + onProgress: (Int) -> Unit + ): Result = withContext(Dispatchers.IO) { + try { + val url = URL(downloadUrl) + val conn = url.openConnection() as HttpURLConnection + conn.connectTimeout = 15000 + conn.readTimeout = 15000 + conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App") + + val fileLength = conn.contentLength + val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: context.filesDir + val apkFile = File(dir, "Escape-penacony-update.apk") + + conn.inputStream.use { input -> + FileOutputStream(apkFile).use { output -> + val buffer = ByteArray(8 * 1024) + var total: Long = 0 + var count: Int + while (input.read(buffer).also { count = it } != -1) { + total += count + if (fileLength > 0) { + val progress = ((total * 100) / fileLength).toInt() + withContext(Dispatchers.Main) { + onProgress(progress) + } + } + output.write(buffer, 0, count) + } + } + } + + Result.success(apkFile) + } catch (e: Exception) { + e.printStackTrace() + Result.failure(e) + } + } + + fun promptInstallApk(context: Context, apkFile: File) { + // Android 8.0+ Check Unknown App Sources permission + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + if (!context.packageManager.canRequestPackageInstalls()) { + val settingsIntent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(settingsIntent) + return + } + } + + val apkUri: Uri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + apkFile + ) + + val installIntent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(apkUri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(installIntent) + } + + suspend fun fetchLatestScenesJson(): Result = withContext(Dispatchers.IO) { + try { + val url = URL(GITHUB_RAW_SCENES_URL) + val conn = url.openConnection() as HttpURLConnection + conn.connectTimeout = 8000 + conn.readTimeout = 8000 + conn.setRequestProperty("User-Agent", "Escape-Penacony-Android-App") + + if (conn.responseCode == 200) { + val content = conn.inputStream.bufferedReader().use { it.readText() } + Result.success(content) + } else { + Result.failure(Exception("HTTP error ${conn.responseCode}")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + private fun isNewerVersion(current: String, latest: String): Boolean { + val c = current.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } + val l = latest.removePrefix("v").split(".").map { it.toIntOrNull() ?: 0 } + val maxLen = maxOf(c.size, l.size) + for (i in 0 until maxLen) { + val cv = c.getOrElse(i) { 0 } + val lv = l.getOrElse(i) { 0 } + if (lv > cv) return true + if (lv < cv) return false + } + return false + } +} diff --git a/app/src/main/java/com/catlas/escapepenacony/model/PersistentConfig.kt b/app/src/main/java/com/catlas/escapepenacony/model/PersistentConfig.kt new file mode 100644 index 0000000..aa85026 --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/model/PersistentConfig.kt @@ -0,0 +1,87 @@ +package com.catlas.escapepenacony.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) + ) + } + } +} + +object PersistentHelper { + fun updateJsonString(originalJson: String, sceneConfig: SceneConfig): String { + val root = try { + if (originalJson.trim().isNotEmpty()) JSONObject(originalJson) else JSONObject() + } catch (e: Exception) { + JSONObject() + } + + root.put("scene", sceneConfig.toJsonObject()) + return root.toString(2) + } +} diff --git a/app/src/main/java/com/catlas/escapepenacony/model/SceneModels.kt b/app/src/main/java/com/catlas/escapepenacony/model/SceneModels.kt new file mode 100644 index 0000000..025bb3a --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/model/SceneModels.kt @@ -0,0 +1,64 @@ +package com.catlas.escapepenacony.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 +) { + 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 + } +} + +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/catlas/escapepenacony/ui/Components.kt b/app/src/main/java/com/catlas/escapepenacony/ui/Components.kt new file mode 100644 index 0000000..9f8d3ad --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/ui/Components.kt @@ -0,0 +1,299 @@ +package com.catlas.escapepenacony.ui + +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.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.catlas.escapepenacony.model.AppLanguage +import com.catlas.escapepenacony.model.FilterCategory +import com.catlas.escapepenacony.model.SceneItem +import com.catlas.escapepenacony.ui.theme.* + +@Composable +fun FilterChipRow( + selectedCategory: FilterCategory, + onCategorySelected: (FilterCategory) -> Unit, + counts: Map +) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterItem( + text = "All (${counts[FilterCategory.ALL] ?: 0})", + selected = selectedCategory == FilterCategory.ALL, + color = PurplePrimary, + onClick = { onCategorySelected(FilterCategory.ALL) } + ) + FilterItem( + text = "⚔️ Combat (${counts[FilterCategory.COMBAT] ?: 0})", + selected = selectedCategory == FilterCategory.COMBAT, + color = CombatGreen, + onClick = { onCategorySelected(FilterCategory.COMBAT) } + ) + FilterItem( + text = "🗺️ Explore (${counts[FilterCategory.EXPLORE] ?: 0})", + selected = selectedCategory == FilterCategory.EXPLORE, + color = ExploreAmber, + onClick = { onCategorySelected(FilterCategory.EXPLORE) } + ) + FilterItem( + text = "🧪 Beta (${counts[FilterCategory.BETA] ?: 0})", + selected = selectedCategory == FilterCategory.BETA, + color = BetaPurple, + onClick = { onCategorySelected(FilterCategory.BETA) } + ) + } +} + +@Composable +fun RowScope.FilterItem( + text: String, + selected: Boolean, + color: Color, + onClick: () -> Unit +) { + Surface( + modifier = Modifier + .weight(1f) + .height(36.dp) + .clip(RoundedCornerShape(18.dp)) + .clickable(onClick = onClick), + color = if (selected) color.copy(alpha = 0.25f) else DarkSurfaceElevated, + border = if (selected) androidx.compose.foundation.BorderStroke(1.5.dp, color) else null + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = text, + fontSize = 11.sp, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Normal, + color = if (selected) color else TextLight + ) + } + } +} + +@Composable +fun SceneCard( + scene: SceneItem, + language: AppLanguage, + onTeleportClick: (SceneItem) -> Unit, + onCopyJsonClick: (SceneItem) -> Unit +) { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = DarkSurface), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(14.dp) + ) { + // Title & Badges + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = scene.localizedName(language), + fontWeight = FontWeight.Bold, + fontSize = 15.sp, + color = TextLight, + modifier = Modifier.weight(1f) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + // Badge + if (scene.isBeta) { + Badge(text = "🧪 4.7 Beta", color = BetaPurple) + } else if (scene.combatReady) { + Badge(text = "⚔️ Combat Ready", color = CombatGreen) + } else { + Badge(text = "🗺️ Safe Scenery", color = ExploreAmber) + } + } + + Spacer(modifier = Modifier.height(6.dp)) + + // Coords & Plane ID + Text( + text = "Plane: ${scene.planeId} | X: ${scene.x} Y: ${scene.y} Z: ${scene.z} (Layer ${scene.mapLayer})", + fontSize = 12.sp, + color = NeonCyan, + fontWeight = FontWeight.Medium + ) + + // Desc + val desc = scene.localizedDesc(language) + if (desc.isNotEmpty()) { + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = desc, + fontSize = 12.sp, + color = TextMuted, + lineHeight = 16.sp + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + // Buttons + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + OutlinedButton( + onClick = { onCopyJsonClick(scene) }, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + modifier = Modifier.height(34.dp), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = "Copy JSON", + modifier = Modifier.size(14.dp), + tint = TextLight + ) + Spacer(modifier = Modifier.width(4.dp)) + Text("Copy JSON", fontSize = 11.sp, color = TextLight) + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { onTeleportClick(scene) }, + contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp), + modifier = Modifier.height(34.dp), + colors = ButtonDefaults.buttonColors(containerColor = PurplePrimary), + shape = RoundedCornerShape(8.dp) + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.Send, + contentDescription = "Teleport", + modifier = Modifier.size(14.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text("Teleport", fontSize = 12.sp, fontWeight = FontWeight.Bold) + } + } + } + } +} + +@Composable +fun Badge(text: String, color: Color) { + Surface( + color = color.copy(alpha = 0.15f), + shape = RoundedCornerShape(6.dp), + border = androidx.compose.foundation.BorderStroke(1.dp, color.copy(alpha = 0.6f)) + ) { + Text( + text = text, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = color, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp) + ) + } +} + +@Composable +fun CustomCoordsDialog( + onDismiss: () -> Unit, + onConfirm: (Long, Long, Long, Long, Int) -> Unit +) { + var planeIdText by remember { mutableStateOf("20461") } + var xText by remember { mutableStateOf("12080") } + var yText by remember { mutableStateOf("393572") } + var zText by remember { mutableStateOf("322230") } + var layerText by remember { mutableStateOf("1") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Custom Coordinates", color = TextLight, fontWeight = FontWeight.Bold) }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = planeIdText, + onValueChange = { planeIdText = it }, + label = { Text("Plane ID (e.g. 20461)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = xText, + onValueChange = { xText = it }, + label = { Text("X") }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = yText, + onValueChange = { yText = it }, + label = { Text("Y") }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + OutlinedTextField( + value = zText, + onValueChange = { zText = it }, + label = { Text("Z") }, + singleLine = true, + modifier = Modifier.weight(1f) + ) + } + OutlinedTextField( + value = layerText, + onValueChange = { layerText = it }, + label = { Text("Map Layer (Default: 1)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + Button( + onClick = { + val p = planeIdText.toLongOrNull() ?: 20461 + val x = xText.toLongOrNull() ?: 0 + val y = yText.toLongOrNull() ?: 0 + val z = zText.toLongOrNull() ?: 0 + val l = layerText.toIntOrNull() ?: 1 + onConfirm(p, x, y, z, l) + }, + colors = ButtonDefaults.buttonColors(containerColor = PurplePrimary) + ) { + Text("Apply") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel", color = TextMuted) + } + }, + containerColor = DarkSurfaceElevated + ) +} diff --git a/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt b/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt new file mode 100644 index 0000000..114157a --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/ui/MainScreen.kt @@ -0,0 +1,494 @@ +package com.catlas.escapepenacony.ui + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.net.Uri +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +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.rememberScrollState +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.catlas.escapepenacony.BuildConfig +import com.catlas.escapepenacony.data.ReleaseInfo +import com.catlas.escapepenacony.data.SceneRepository +import com.catlas.escapepenacony.data.StorageManager +import com.catlas.escapepenacony.data.UpdateManager +import com.catlas.escapepenacony.model.* +import com.catlas.escapepenacony.ui.theme.* +import kotlinx.coroutines.launch + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + currentPersistentUri: Uri?, + onPickPersistentFile: () -> Unit +) { + val context = LocalContext.current + val coroutineScope = 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 language by remember { mutableStateOf(AppLanguage.EN) } + + var currentActiveConfig by remember { mutableStateOf(null) } + var showCustomDialog by remember { mutableStateOf(false) } + + // Update States + var showUpdateDialog by remember { mutableStateOf(false) } + var releaseInfo by remember { mutableStateOf(null) } + var isDownloadingUpdate by remember { mutableStateOf(false) } + var downloadProgress by remember { mutableStateOf(0) } + var isCheckingUpdate by remember { mutableStateOf(false) } + + // Load initial data + LaunchedEffect(Unit) { + val savedLang = StorageManager.getSavedLanguage(context) + language = AppLanguage.values().firstOrNull { it.code == savedLang } ?: AppLanguage.EN + planets = SceneRepository.loadScenes(context) + if (planets.isNotEmpty()) { + selectedPlanetId = planets.first().id + } + } + + // Refresh active config when persistent URI changes + LaunchedEffect(currentPersistentUri) { + if (currentPersistentUri != null) { + currentActiveConfig = StorageManager.readCurrentConfig(context, currentPersistentUri) + } else { + currentActiveConfig = null + } + } + + // Filter scenes + val activePlanet = planets.firstOrNull { it.id == selectedPlanetId } + val displayedScenes = remember(activePlanet, selectedCategory, searchQuery, language) { + val baseScenes = activePlanet?.scenes ?: emptyList() + baseScenes.filter { scene -> + val matchCategory = when (selectedCategory) { + FilterCategory.ALL -> true + FilterCategory.COMBAT -> scene.combatReady + FilterCategory.EXPLORE -> !scene.combatReady && !scene.isBeta + FilterCategory.BETA -> scene.isBeta + } + val matchQuery = 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) + } + matchCategory && matchQuery + } + } + + // Category Counts + val counts = remember(activePlanet) { + val list = activePlanet?.scenes ?: emptyList() + mapOf( + FilterCategory.ALL to list.size, + FilterCategory.COMBAT to list.count { it.combatReady }, + FilterCategory.EXPLORE to list.count { !it.combatReady && !it.isBeta }, + FilterCategory.BETA to list.count { it.isBeta } + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { + Column { + Text( + text = "Escape penacony standalone", + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + color = TextLight + ) + Text( + text = "Escape penacony | ${BuildConfig.VERSION_NAME}", + fontSize = 11.sp, + color = NeonCyan + ) + } + }, + actions = { + // Language Switcher + IconButton(onClick = { + val nextLang = when (language) { + AppLanguage.EN -> AppLanguage.TH + AppLanguage.TH -> AppLanguage.ZH + AppLanguage.ZH -> AppLanguage.EN + } + language = nextLang + StorageManager.saveLanguage(context, nextLang.code) + }) { + Badge(text = language.name, color = StarGold) + } + + // Hot OTA scenes.json update + IconButton(onClick = { + coroutineScope.launch { + Toast.makeText(context, "Checking map updates...", Toast.LENGTH_SHORT).show() + val res = UpdateManager.fetchLatestScenesJson() + res.onSuccess { jsonStr -> + val ok = SceneRepository.saveUpdatedScenesJson(context, jsonStr) + if (ok) { + planets = SceneRepository.loadScenes(context) + Toast.makeText(context, "Maps updated successfully!", Toast.LENGTH_LONG).show() + } else { + Toast.makeText(context, "Map data invalid", Toast.LENGTH_SHORT).show() + } + }.onFailure { + Toast.makeText(context, "Update map failed: ${it.message}", Toast.LENGTH_SHORT).show() + } + } + }) { + Icon(Icons.Default.CloudSync, contentDescription = "Sync Maps", tint = NeonCyan) + } + + // Check APK Update + IconButton(onClick = { + if (isCheckingUpdate) return@IconButton + isCheckingUpdate = true + coroutineScope.launch { + Toast.makeText(context, "Checking latest release...", Toast.LENGTH_SHORT).show() + val result = UpdateManager.checkForAppUpdate() + isCheckingUpdate = false + result.onSuccess { info -> + if (info != null && info.isNewer) { + releaseInfo = info + showUpdateDialog = true + } else { + Toast.makeText(context, "You are on the latest version (${BuildConfig.VERSION_NAME})", Toast.LENGTH_LONG).show() + } + }.onFailure { + Toast.makeText(context, "Check update failed: ${it.message}", Toast.LENGTH_SHORT).show() + } + } + }) { + Icon(Icons.Default.SystemUpdate, contentDescription = "Check Update", tint = StarGold) + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = DarkSurfaceElevated) + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { showCustomDialog = true }, + containerColor = PurplePrimary, + contentColor = TextLight + ) { + Icon(Icons.Default.AddLocationAlt, contentDescription = "Custom Teleport") + } + }, + containerColor = DarkBackground + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + // Persistent Target File Section + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + shape = RoundedCornerShape(10.dp), + color = DarkSurface + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (currentPersistentUri != null) "Target: persistent.json" else "Target: Not Selected", + fontWeight = FontWeight.Bold, + fontSize = 13.sp, + color = if (currentPersistentUri != null) CombatGreen else TextMuted + ) + if (currentActiveConfig != null) { + Text( + text = "Current Plane: ${currentActiveConfig?.planeId} (Layer ${currentActiveConfig?.mapLayer})", + fontSize = 11.sp, + color = NeonCyan + ) + } else { + Text( + text = "Select file to enable one-tap teleport", + fontSize = 11.sp, + color = TextMuted + ) + } + } + + Button( + onClick = onPickPersistentFile, + shape = RoundedCornerShape(8.dp), + colors = ButtonDefaults.buttonColors( + containerColor = if (currentPersistentUri != null) DarkSurfaceElevated else PurplePrimary + ), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), + modifier = Modifier.height(32.dp) + ) { + Text( + text = if (currentPersistentUri != null) "Change" else "Choose File", + fontSize = 11.sp + ) + } + } + } + + // Search Bar + OutlinedTextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + placeholder = { Text("Search scenes (e.g. Penacony, 20461, Parlor Car)...", fontSize = 12.sp) }, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = TextMuted) }, + trailingIcon = { + if (searchQuery.isNotEmpty()) { + IconButton(onClick = { searchQuery = "" }) { + Icon(Icons.Default.Close, contentDescription = "Clear", tint = TextMuted) + } + } + }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + shape = RoundedCornerShape(10.dp), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = PurplePrimary, + unfocusedBorderColor = BorderSubtle, + focusedContainerColor = DarkSurface, + unfocusedContainerColor = DarkSurface + ) + ) + + // Planet Horizontal Tabs + if (planets.isNotEmpty()) { + LazyRow( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(planets) { planet -> + val isSelected = planet.id == selectedPlanetId + Surface( + shape = RoundedCornerShape(16.dp), + color = if (isSelected) PurplePrimary else DarkSurface, + modifier = Modifier + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable { selectedPlanetId = planet.id } + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 12.dp) + ) { + Text( + text = planet.localizedName(language), + fontSize = 12.sp, + fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, + color = if (isSelected) TextLight else TextMuted + ) + } + } + } + } + } + + // Filter Category Chips + FilterChipRow( + selectedCategory = selectedCategory, + onCategorySelected = { selectedCategory = it }, + counts = counts + ) + + // Scene List + if (displayedScenes.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text("No scenes match your filter", color = TextMuted, fontSize = 13.sp) + } + } else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .weight(1f), + contentPadding = PaddingValues(vertical = 6.dp) + ) { + items(displayedScenes, key = { it.id }) { scene -> + SceneCard( + scene = scene, + language = language, + onTeleportClick = { targetScene -> + if (currentPersistentUri == null) { + Toast.makeText(context, "Please choose persistent.json first!", Toast.LENGTH_SHORT).show() + onPickPersistentFile() + return@SceneCard + } + + val cfg = SceneConfig.fromScene(targetScene) + coroutineScope.launch { + val res = StorageManager.applySceneConfig(context, currentPersistentUri, cfg) + res.onSuccess { + currentActiveConfig = cfg + Toast.makeText( + context, + "Teleported to ${targetScene.localizedName(language)} (Plane ${targetScene.planeId})!", + Toast.LENGTH_SHORT + ).show() + }.onFailure { + Toast.makeText(context, "Teleport failed: ${it.message}", Toast.LENGTH_LONG).show() + } + } + }, + onCopyJsonClick = { targetScene -> + val cfg = SceneConfig.fromScene(targetScene) + val jsonStr = StorageManager.exportTemplateJson(context, cfg) + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("ppSR Teleport JSON", jsonStr)) + Toast.makeText(context, "Teleport JSON copied to clipboard!", Toast.LENGTH_SHORT).show() + } + ) + } + } + } + } + } + + // Custom Coordinates Dialog + if (showCustomDialog) { + CustomCoordsDialog( + onDismiss = { showCustomDialog = false }, + onConfirm = { planeId, x, y, z, layer -> + showCustomDialog = false + val cfg = SceneConfig( + planeId = planeId, + x = x, + y = y, + z = z, + calyxX = x, + calyxY = y, + calyxZ = z + 1800, + mapLayer = layer + ) + if (currentPersistentUri == null) { + val jsonStr = StorageManager.exportTemplateJson(context, cfg) + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText("ppSR Custom Teleport JSON", jsonStr)) + Toast.makeText(context, "JSON copied! Please choose persistent.json to apply directly.", Toast.LENGTH_LONG).show() + } else { + coroutineScope.launch { + val res = StorageManager.applySceneConfig(context, currentPersistentUri, cfg) + res.onSuccess { + currentActiveConfig = cfg + Toast.makeText(context, "Custom coordinates applied!", Toast.LENGTH_SHORT).show() + }.onFailure { + Toast.makeText(context, "Failed: ${it.message}", Toast.LENGTH_LONG).show() + } + } + } + } + ) + } + + // Update Dialog + if (showUpdateDialog && releaseInfo != null) { + val info = releaseInfo!! + AlertDialog( + onDismissRequest = { + if (!isDownloadingUpdate) showUpdateDialog = false + }, + title = { + Text( + text = "Update Available: ${info.tagName}", + color = TextLight, + fontWeight = FontWeight.Bold + ) + }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(info.releaseTitle, fontWeight = FontWeight.SemiBold, color = NeonCyan) + Text(info.changelog, fontSize = 12.sp, color = TextMuted) + + if (isDownloadingUpdate) { + Spacer(modifier = Modifier.height(8.dp)) + LinearProgressIndicator( + progress = { downloadProgress / 100f }, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "Downloading APK: $downloadProgress%", + fontSize = 11.sp, + color = NeonCyan + ) + } + } + }, + confirmButton = { + Button( + onClick = { + if (info.apkDownloadUrl.isEmpty()) { + Toast.makeText(context, "No APK asset in release", Toast.LENGTH_SHORT).show() + return@Button + } + isDownloadingUpdate = true + coroutineScope.launch { + val res = UpdateManager.downloadApk(context, info.apkDownloadUrl) { p -> + downloadProgress = p + } + isDownloadingUpdate = false + res.onSuccess { apkFile -> + showUpdateDialog = false + UpdateManager.promptInstallApk(context, apkFile) + }.onFailure { + Toast.makeText(context, "Download failed: ${it.message}", Toast.LENGTH_SHORT).show() + } + } + }, + enabled = !isDownloadingUpdate, + colors = ButtonDefaults.buttonColors(containerColor = PurplePrimary) + ) { + Text(if (isDownloadingUpdate) "Downloading..." else "Update Now") + } + }, + dismissButton = { + if (!isDownloadingUpdate) { + TextButton(onClick = { showUpdateDialog = false }) { + Text("Later", color = TextMuted) + } + } + }, + containerColor = DarkSurfaceElevated + ) + } +} diff --git a/app/src/main/java/com/catlas/escapepenacony/ui/theme/Color.kt b/app/src/main/java/com/catlas/escapepenacony/ui/theme/Color.kt new file mode 100644 index 0000000..a3ce581 --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/ui/theme/Color.kt @@ -0,0 +1,17 @@ +package com.catlas.escapepenacony.ui.theme + +import androidx.compose.ui.graphics.Color + +val PurplePrimary = Color(0xFF7C4DFF) +val PurpleDark = Color(0xFF512DA8) +val NeonCyan = Color(0xFF00E5FF) +val StarGold = Color(0xFFFFD700) +val DarkBackground = Color(0xFF0E0F14) +val DarkSurface = Color(0xFF171922) +val DarkSurfaceElevated = Color(0xFF202330) +val TextLight = Color(0xFFF1F1F5) +val TextMuted = Color(0xFF9E9EA7) +val CombatGreen = Color(0xFF00E676) +val ExploreAmber = Color(0xFFFFAB00) +val BetaPurple = Color(0xFFE040FB) +val BorderSubtle = Color(0xFF2C2F40) diff --git a/app/src/main/java/com/catlas/escapepenacony/ui/theme/Theme.kt b/app/src/main/java/com/catlas/escapepenacony/ui/theme/Theme.kt new file mode 100644 index 0000000..93053d5 --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/ui/theme/Theme.kt @@ -0,0 +1,44 @@ +package com.catlas.escapepenacony.ui.theme + +import android.app.Activity +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = PurplePrimary, + secondary = NeonCyan, + tertiary = StarGold, + background = DarkBackground, + surface = DarkSurface, + onPrimary = TextLight, + onSecondary = DarkBackground, + onBackground = TextLight, + onSurface = TextLight +) + +@Composable +fun EscapePenaconyTheme( + content: @Composable () -> Unit +) { + val colorScheme = DarkColorScheme + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.background.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = false + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} diff --git a/app/src/main/java/com/catlas/escapepenacony/ui/theme/Type.kt b/app/src/main/java/com/catlas/escapepenacony/ui/theme/Type.kt new file mode 100644 index 0000000..9110564 --- /dev/null +++ b/app/src/main/java/com/catlas/escapepenacony/ui/theme/Type.kt @@ -0,0 +1,31 @@ +package com.catlas.escapepenacony.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( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ), + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Bold, + fontSize = 20.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) +) 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/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..aa438ca --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Escape penacony + Escape penacony standalone + 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..ed0c8cb --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + 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..e6091a9 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,27 @@ +[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" + +[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" } + +[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..6ba1d1c --- /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 = "Escape penacony standalone" +include(":app")