feat(ui): 接通 Kotlin/React,迁移车库界面并导出物品目录 fixture
This commit is contained in:
1 parent
6c1e8efb49
commit
aa3903e92c
14 files changed
+6868
-37
No files matched your search
@@ -219,3 +219,12 @@ TankiOffline (Kotlin/JS 客户端)
|
||||
- 大厅:L0/L1 骨架(官方图标、真实翻译、物品目录、连接状态)。
|
||||
- 资源:本地镜像已接入。
|
||||
- 已确认:技术栈 Kotlin/React、组件级还原、本地 mock、3D 现在做、首批大厅+车库、允许混跑。
|
||||
|
||||
## 8. 实现进度(滚动更新)
|
||||
|
||||
- [x] Kotlin/React 工具链接通(kotlin-react / kotlin-react-dom / kotlin-emotion,React 19.1)。
|
||||
- [x] 物品目录 fixture:`SessionCheck` 从抓包导出 `resources/fixtures/catalog.json`(1399 物品)。
|
||||
- [x] 车库第一版 React 界面:分类页签(12 类)、搜索、物品网格(120 张卡片)、右侧详情位。
|
||||
- [ ] 车库物品详情 / 3D 预览加载器。
|
||||
- [ ] 协议级 mock 服务器(用户 / 大厅列表 / 任务)。
|
||||
- [ ] 大厅其余屏幕迁移到 React。
|
||||
@@ -2,7 +2,8 @@
|
||||
build/
|
||||
.gradle/
|
||||
.kotlin/
|
||||
kotlin-js-store/
|
||||
kotlin-js-store/*
|
||||
!kotlin-js-store/package-lock.json
|
||||
node_modules/
|
||||
|
||||
# 说明:net/src/jsMain/kotlin/Net/Generated/ 是代码生成产物,但要入库并允许手改,
|
||||
|
||||
@@ -17,6 +17,10 @@ kotlin {
|
||||
implementation(project(":state"))
|
||||
// 自动测试用 ws 客户端:需要自定义 User-Agent(真实服务器会拒绝 Node 默认 UA)
|
||||
implementation(npm("ws", "8.18.0"))
|
||||
// UI:Kotlin/React(与原客户端同栈;kotlin-wrappers 会带入 react / react-dom npm 依赖)
|
||||
implementation("org.jetbrains.kotlin-wrappers:kotlin-react:2025.5.0-19.1.0")
|
||||
implementation("org.jetbrains.kotlin-wrappers:kotlin-react-dom:2025.5.0-19.1.0")
|
||||
implementation("org.jetbrains.kotlin-wrappers:kotlin-emotion:2025.3.26")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
package App.Browser
|
||||
|
||||
fun BrowserMain() {
|
||||
val Params = js("new URLSearchParams(window.location.search)")
|
||||
if (Params.get("react") == "1") {
|
||||
MountReactSmoke()
|
||||
return
|
||||
}
|
||||
|
||||
lateinit var Client: LobbyClient
|
||||
lateinit var Lobby: LobbyView
|
||||
lateinit var Entrance: EntranceView
|
||||
@@ -27,7 +33,6 @@ fun BrowserMain() {
|
||||
Translations.Load { Entrance.Update() }
|
||||
|
||||
// 调试便利:?autoconnect=1&lobby=ws://…&space=ws://…
|
||||
val Params = js("new URLSearchParams(window.location.search)")
|
||||
if (Params.get("autoconnect") == "1") {
|
||||
val LobbyUrl = (Params.get("lobby") as? String) ?: "ws://127.0.0.1:9091/"
|
||||
val SpaceUrl = (Params.get("space") as? String) ?: LobbyUrl
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
// 车库 / 商店 / 幸运礼盒直接读 ItemCatalog(抓包解出的 1399 物品 + 516 装置)。
|
||||
package App.Browser
|
||||
|
||||
import App.Browser.Ui.GarageMount
|
||||
import App.Browser.Ui.UiCatalog
|
||||
import App.State.GarageItemObject
|
||||
import Net.Generated.DescriptionModelCC
|
||||
|
||||
@@ -60,6 +62,7 @@ class LobbyView(private val Client: LobbyClient) {
|
||||
private var Category: Int = 0
|
||||
private var Query: String = ""
|
||||
private var Mounted = false
|
||||
private var GarageMounted = false
|
||||
|
||||
fun Mount() {
|
||||
if (Mounted) return
|
||||
@@ -93,7 +96,17 @@ class LobbyView(private val Client: LobbyClient) {
|
||||
</div>
|
||||
"""
|
||||
)
|
||||
if (Nav != "garage" && GarageMounted) {
|
||||
GarageMount.Unmount()
|
||||
GarageMounted = false
|
||||
}
|
||||
if (!(Nav == "garage" && GarageMounted)) {
|
||||
SetHtml("lobby-content", Content())
|
||||
}
|
||||
if (Nav == "garage" && UiCatalog.Loaded) {
|
||||
GarageMount.Mount(UiCatalog.Items)
|
||||
GarageMounted = true
|
||||
}
|
||||
SetHtml("lobby-chat", Chat())
|
||||
SetHtml(
|
||||
"lobby-status",
|
||||
@@ -309,36 +322,15 @@ class LobbyView(private val Client: LobbyClient) {
|
||||
}
|
||||
|
||||
private fun Garage(): String {
|
||||
val All = Client.Router.Catalog.Items.values
|
||||
val Filtered = All.filter {
|
||||
ItemCategory(it) == Category && (Query.isEmpty() || ItemName(it).contains(Query, ignoreCase = true))
|
||||
}
|
||||
val Shown = Filtered.take(96)
|
||||
if (!UiCatalog.Loaded) {
|
||||
UiCatalog.Ensure(Client) { Refresh() }
|
||||
return """
|
||||
<section class="section-head">
|
||||
<h2>${Esc(Translations.T("lobby.menu.GARAGE", "车库"))}
|
||||
<small>${Filtered.size} / ${All.size}</small>
|
||||
</h2>
|
||||
<input id="garage-search" class="search" placeholder="搜索物品" value="${Esc(Query)}">
|
||||
</section>
|
||||
<div class="tabs">
|
||||
${Categories.joinToString("") { (Id, Key, Fallback) ->
|
||||
val Count = All.count { ItemCategory(it) == Id }
|
||||
"""
|
||||
<button class="tab ${if (Category == Id) "active" else ""}" data-garage-cat="$Id">
|
||||
${Esc(Translations.T("lobby.garage.$Key", Fallback))}<i>$Count</i>
|
||||
</button>
|
||||
""".trimIndent()
|
||||
}}
|
||||
</div>
|
||||
${if (All.isEmpty()) """
|
||||
<div class="empty">目录还是空的:连接服务器后会解出车库物品(当前离线目录 0 个)。</div>
|
||||
""" else """
|
||||
<div class="item-grid">${Shown.joinToString("") { ItemCard(it) }}</div>
|
||||
${if (Filtered.size > Shown.size) "<p class=\"hint\">仅显示前 ${Shown.size} 件,继续补充分页。</p>" else ""}
|
||||
"""}
|
||||
<section class="section-head"><h2>${Esc(Translations.T("lobby.menu.GARAGE", "车库"))}</h2></section>
|
||||
<div class="empty" id="garage-loading">正在加载物品目录…</div>
|
||||
""".trimIndent()
|
||||
}
|
||||
return """<section class="garage-host"><div id="garage-root"></div></section>"""
|
||||
}
|
||||
|
||||
private fun Shop(): String {
|
||||
val All = Client.Router.Catalog.Items.values.filter { it.buyable }
|
||||
@@ -404,18 +396,15 @@ class LobbyView(private val Client: LobbyClient) {
|
||||
<div class="empty">${Esc(Text)}</div>
|
||||
""".trimIndent()
|
||||
|
||||
private fun Side(): String {
|
||||
private fun Chat(): String {
|
||||
val Lines = Client.Logs.takeLast(12).joinToString("") { "<div class=\"log-line\">${Esc(it)}</div>" }
|
||||
return """
|
||||
<div class="panel">
|
||||
<h3>${Esc(Translations.T("lobby.menu.CHAT", "聊天"))}</h3>
|
||||
<div class="chat-head">
|
||||
<span>${Esc(Translations.T("lobby.menu.CHAT", "聊天"))}</span>
|
||||
<span class="muted">${Client.Status}</span>
|
||||
</div>
|
||||
<div class="chat-box">${if (Lines.isEmpty()) "<span class=\"muted\">连接后显示服务器日志</span>" else Lines}</div>
|
||||
<div class="chat-input"><input placeholder="聊天暂未开放" disabled><button disabled>发送</button></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h3>${Esc(Translations.T("lobby.menu.NEWS", "新闻"))}</h3>
|
||||
<div class="muted small">新闻、公告与活动由服务端推送后显示在这里。</div>
|
||||
</div>
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// ReactSmoke.kt —— Kotlin/React 工具链探针
|
||||
//
|
||||
// 用途:确认 kotlin-react / react-dom / emotion 依赖可用、能挂载组件。
|
||||
// 打开 `?react=1` 时只渲染这个小组件,不影响正常登录页。
|
||||
package App.Browser
|
||||
|
||||
import react.FC
|
||||
import react.Props
|
||||
import react.create
|
||||
import react.dom.client.createRoot
|
||||
import react.dom.html.ReactHTML.div
|
||||
import react.dom.html.ReactHTML.h1
|
||||
import react.dom.html.ReactHTML.p
|
||||
import web.dom.document
|
||||
import web.html.HTMLDivElement
|
||||
|
||||
private val Smoke = FC<Props> {
|
||||
div {
|
||||
h1 { +"Kotlin/React OK" }
|
||||
p { +"kotlin-react + react-dom 依赖已接通。" }
|
||||
}
|
||||
}
|
||||
|
||||
fun MountReactSmoke() {
|
||||
val Root = createRoot(document.getElementById("app") as HTMLDivElement)
|
||||
Root.render(Smoke.create())
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Categories.kt —— 车库分类(数值来自 module_85667 的 ItemCategory 枚举)
|
||||
package App.Browser.Ui
|
||||
|
||||
import App.Browser.Translations
|
||||
|
||||
data class GarageCategory(val Id: Int, val Key: String, val Default: String) {
|
||||
val Label: String get() = Translations.T("lobby.garage.$Key", Default)
|
||||
}
|
||||
|
||||
val GarageCategories: List<GarageCategory> = listOf(
|
||||
GarageCategory(0, "WEAPON", "炮塔"),
|
||||
GarageCategory(1, "ARMOR", "底盘"),
|
||||
GarageCategory(2, "PAINT", "迷彩"),
|
||||
GarageCategory(3, "INVENTORY", "道具"),
|
||||
GarageCategory(5, "KIT", "套件"),
|
||||
GarageCategory(10, "RESISTANCE_MODULE", "防御模块"),
|
||||
GarageCategory(11, "DEVICE", "装置"),
|
||||
GarageCategory(13, "CONTAINER", "幸运礼盒"),
|
||||
GarageCategory(14, "DRONE", "辅助无人机"),
|
||||
GarageCategory(15, "SKIN", "皮肤"),
|
||||
GarageCategory(17, "BAZOOKA", "火箭炮"),
|
||||
GarageCategory(18, "GRENADE", "手榴弹"),
|
||||
)
|
||||
|
||||
fun CategoryLabel(Id: Int): String =
|
||||
GarageCategories.firstOrNull { it.Id == Id }?.Label ?: "其它"
|
||||
@@ -0,0 +1,166 @@
|
||||
// GarageScreen.kt —— React 车库(大厅第一个正式迁移的界面)
|
||||
//
|
||||
// 数据:UiCatalog(真实 ItemCatalog 或抓包导出的 fixture)。
|
||||
// 结构对照原版:分类页签 + 物品网格 + 右侧详情 / 3D 预览位。
|
||||
package App.Browser.Ui
|
||||
|
||||
import App.Browser.Translations
|
||||
import react.FC
|
||||
import react.Props
|
||||
import react.create
|
||||
import react.dom.client.createRoot
|
||||
import react.dom.html.HTMLAttributes
|
||||
import react.dom.html.ReactHTML.aside
|
||||
import react.dom.html.ReactHTML.b
|
||||
import react.dom.html.ReactHTML.button
|
||||
import react.dom.html.ReactHTML.div
|
||||
import react.dom.html.ReactHTML.h2
|
||||
import react.dom.html.ReactHTML.h3
|
||||
import react.dom.html.ReactHTML.input
|
||||
import react.dom.html.ReactHTML.section
|
||||
import react.dom.html.ReactHTML.small
|
||||
import react.dom.html.ReactHTML.span
|
||||
import react.useState
|
||||
import web.dom.document
|
||||
import web.html.HTMLDivElement
|
||||
|
||||
private val RarityNames = listOf("普通", "罕见", "稀有", "史诗", "传奇", "特殊")
|
||||
|
||||
private fun RarityName(R: Int): String = RarityNames.getOrElse(R) { "普通" }
|
||||
|
||||
/** kotlin-react 19 的 className 是 ClassName 类型;直接走 DOM 属性避免类型转换样板。 */
|
||||
private fun HTMLAttributes<*>.Css(Value: String) {
|
||||
val E = this.asDynamic()
|
||||
E.className = Value
|
||||
}
|
||||
|
||||
private val GarageScreen = FC<Props> {
|
||||
var Category by useState(-1)
|
||||
var Query by useState("")
|
||||
var Selected by useState<CatalogItem?>(null)
|
||||
|
||||
val All = UiCatalog.Items
|
||||
val Counts = GarageCategories.associate { C -> C.Id to All.count { it.Category == C.Id } }
|
||||
val ActiveCategory = if (Category >= 0) Category
|
||||
else GarageCategories.firstOrNull { (Counts[it.Id] ?: 0) > 0 }?.Id ?: 0
|
||||
val Filtered = All.filter {
|
||||
it.Category == ActiveCategory && (Query.isBlank() || it.Name.contains(Query, ignoreCase = true))
|
||||
}
|
||||
val Shown = Filtered.take(120)
|
||||
|
||||
section {
|
||||
Css("garage")
|
||||
|
||||
div {
|
||||
Css("garage-head")
|
||||
h2 {
|
||||
+Translations.T("lobby.menu.GARAGE", "车库")
|
||||
small { +"${Filtered.size} / ${All.size}" }
|
||||
}
|
||||
input {
|
||||
Css("garage-search")
|
||||
placeholder = "搜索物品"
|
||||
value = Query
|
||||
onChange = { E -> Query = E.target.value }
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
Css("garage-tabs")
|
||||
for (C in GarageCategories) {
|
||||
val N = Counts[C.Id] ?: 0
|
||||
if (N == 0) continue
|
||||
button {
|
||||
Css("garage-tab" + if (C.Id == ActiveCategory) " active" else "")
|
||||
onClick = { Category = C.Id }
|
||||
+C.Label
|
||||
span { +"$N" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
Css("garage-main")
|
||||
|
||||
div {
|
||||
Css("garage-grid")
|
||||
for (Item in Shown) {
|
||||
button {
|
||||
Css(
|
||||
"garage-card rarity-${Item.Rarity}" +
|
||||
if (Selected?.Id == Item.Id) " selected" else ""
|
||||
)
|
||||
onClick = { Selected = Item }
|
||||
div { Css("garage-card-art"); +Item.Name.take(1) }
|
||||
div { Css("garage-card-name"); +Item.Name.ifEmpty { "物品 ${Item.Id}" } }
|
||||
div {
|
||||
Css("garage-card-meta")
|
||||
+CategoryLabel(Item.Category)
|
||||
if (Item.Price > 0 && Item.Buyable) {
|
||||
span { Css("garage-price"); +PriceText(Item) }
|
||||
}
|
||||
}
|
||||
if (Item.Premium) span { Css("garage-premium"); +"VIP" }
|
||||
}
|
||||
}
|
||||
if (Shown.isEmpty()) {
|
||||
div { Css("garage-empty"); +"这个分类暂时没有物品。" }
|
||||
}
|
||||
}
|
||||
|
||||
aside {
|
||||
Css("garage-detail")
|
||||
val Item = Selected
|
||||
if (Item == null) {
|
||||
h3 { +"选择一件物品" }
|
||||
div { Css("garage-preview") }
|
||||
div { Css("garage-preview-label"); +"3D 预览加载器接入中" }
|
||||
} else {
|
||||
h3 { +Item.Name.ifEmpty { "物品 ${Item.Id}" } }
|
||||
div { Css("garage-preview") }
|
||||
div { Css("garage-preview-label"); +"3D 预览加载器接入中" }
|
||||
div { Css("garage-props") }
|
||||
Prop("分类", CategoryLabel(Item.Category))
|
||||
Prop("稀有度", RarityName(Item.Rarity))
|
||||
Prop("物品 ID", Item.Id.toString())
|
||||
if (Item.Count > 0) Prop("数量", Item.Count.toString())
|
||||
if (Item.Buyable) Prop("价格", PriceText(Item))
|
||||
if (Item.Premium) Prop("VIP", "是")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PriceText(Item: CatalogItem): String = when (Item.Currency) {
|
||||
1 -> "${Item.Price} 水晶"
|
||||
2 -> "${Item.Price} 红宝石"
|
||||
else -> "${Item.Price}"
|
||||
}
|
||||
|
||||
private fun react.ChildrenBuilder.Prop(Name: String, Value: String) {
|
||||
div {
|
||||
Css("garage-prop")
|
||||
span { +Name }
|
||||
b { +Value }
|
||||
}
|
||||
}
|
||||
|
||||
object GarageMount {
|
||||
private var Root: dynamic = null
|
||||
|
||||
fun Mount(Items: List<CatalogItem>, Force: Boolean = false) {
|
||||
val El = document.getElementById("garage-root") as? HTMLDivElement ?: return
|
||||
if (Root != null && !Force) return
|
||||
if (Root == null) Root = createRoot(El)
|
||||
Root.render(GarageScreen.create())
|
||||
}
|
||||
|
||||
fun Unmount() {
|
||||
try {
|
||||
Root?.unmount()
|
||||
} catch (_: Throwable) {
|
||||
}
|
||||
Root = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// UiCatalog.kt —— UI 侧的物品目录(React 车库数据源)
|
||||
//
|
||||
// 数据优先来自真实会话(ItemCatalog,连接服务器后填充);没有会话时回退到
|
||||
// `resources/fixtures/catalog.json`(由 SessionCheck 从抓包导出,供离线开发/截图)。
|
||||
package App.Browser.Ui
|
||||
|
||||
import App.Browser.LobbyClient
|
||||
import Net.Generated.DescriptionModelCC
|
||||
|
||||
data class CatalogItem(
|
||||
val Id: Long,
|
||||
val Name: String,
|
||||
val Category: Int,
|
||||
val ViewCategory: Int,
|
||||
val Rarity: Int,
|
||||
val Premium: Boolean,
|
||||
val Count: Int,
|
||||
val Buyable: Boolean,
|
||||
val Price: Int,
|
||||
val Currency: Int,
|
||||
)
|
||||
|
||||
object UiCatalog {
|
||||
|
||||
var Items: List<CatalogItem> = emptyList()
|
||||
private set
|
||||
|
||||
var Loaded: Boolean = false
|
||||
private set
|
||||
|
||||
private var Loading = false
|
||||
private var Waiters: MutableList<() -> Unit> = ArrayList()
|
||||
|
||||
/** 优先真实目录;为空时拉取 fixture。 */
|
||||
fun Ensure(Client: LobbyClient, OnReady: () -> Unit) {
|
||||
val Live = Client.Router.Catalog
|
||||
if (Live.Items.isNotEmpty()) {
|
||||
Items = Live.Items.map { (Id, Item) ->
|
||||
CatalogItem(
|
||||
Id = Id,
|
||||
Name = (Item.description as? DescriptionModelCC)?.Name ?: "",
|
||||
Category = Item.itemCategory,
|
||||
ViewCategory = Item.itemViewCategory,
|
||||
Rarity = Item.rarity as? Int ?: 0,
|
||||
Premium = Item.isPremium,
|
||||
Count = Item.count,
|
||||
Buyable = Item.buyable,
|
||||
Price = Live.Prices[Id] ?: 0,
|
||||
Currency = Live.Currencies[Id] ?: 0,
|
||||
)
|
||||
}
|
||||
Loaded = true
|
||||
OnReady()
|
||||
return
|
||||
}
|
||||
if (Loaded) {
|
||||
OnReady()
|
||||
return
|
||||
}
|
||||
Waiters.add(OnReady)
|
||||
if (Loading) return
|
||||
Loading = true
|
||||
FetchFixture { Text ->
|
||||
if (Text != null) Items = Parse(Text)
|
||||
Loaded = true
|
||||
Loading = false
|
||||
val Pending = Waiters
|
||||
Waiters = ArrayList()
|
||||
for (F in Pending) F()
|
||||
}
|
||||
}
|
||||
|
||||
private val FetchFixture: ((String?) -> Unit) -> Unit = { Callback ->
|
||||
js(
|
||||
"""
|
||||
(function (Cb) {
|
||||
fetch('/fixtures/catalog.json')
|
||||
.then(function (R) { return R.ok ? R.text() : null; })
|
||||
.then(function (T) { Cb(T); })
|
||||
.catch(function () { Cb(null); });
|
||||
})
|
||||
"""
|
||||
)(Callback)
|
||||
}
|
||||
|
||||
private fun Parse(Text: String): List<CatalogItem> {
|
||||
val Out = ArrayList<CatalogItem>()
|
||||
val Data: dynamic = js("JSON.parse(Text)")
|
||||
val Rows: dynamic = Data.items
|
||||
for (I in 0 until (Rows.length as Int)) {
|
||||
val R = Rows[I]
|
||||
Out.add(
|
||||
CatalogItem(
|
||||
Id = (R.id as Number).toLong(),
|
||||
Name = R.name as String,
|
||||
Category = (R.category as Number).toInt(),
|
||||
ViewCategory = (R.viewCategory as Number).toInt(),
|
||||
Rarity = (R.rarity as Number).toInt(),
|
||||
Premium = R.premium as Boolean,
|
||||
Count = (R.count as Number).toInt(),
|
||||
Buyable = R.buyable as Boolean,
|
||||
Price = (R.price as Number).toInt(),
|
||||
Currency = (R.currency as Number).toInt(),
|
||||
)
|
||||
)
|
||||
}
|
||||
return Out
|
||||
}
|
||||
}
|
||||
@@ -120,6 +120,10 @@ class ItemCatalog {
|
||||
val Items: MutableMap<Long, GarageItemObject> = LinkedHashMap()
|
||||
val Devices: MutableMap<Long, GarageDeviceObject> = LinkedHashMap()
|
||||
|
||||
/** 物品价格 / 货币(BuyableCC 的两个字段;GarageItemObject 没地方放,单独存)。 */
|
||||
val Prices: MutableMap<Long, Int> = LinkedHashMap()
|
||||
val Currencies: MutableMap<Long, Int> = LinkedHashMap()
|
||||
|
||||
/** 被识别并吸收的 Pt 项数。 */
|
||||
var ConsumedItems: Int = 0
|
||||
private set
|
||||
@@ -169,6 +173,10 @@ class ItemCatalog {
|
||||
Any = true
|
||||
} else if (IsItem) {
|
||||
Items[ObjectId] = MakeItem(ObjectId, Models)
|
||||
(Models[CatalogModelIds.Buyable] as? BuyableCC)?.let {
|
||||
Prices[ObjectId] = it.Field3
|
||||
Currencies[ObjectId] = it.CurrencyId
|
||||
}
|
||||
Mark(Consumed, Begin, End)
|
||||
Groups++
|
||||
Any = true
|
||||
|
||||
@@ -206,9 +206,50 @@ object SessionCheck {
|
||||
if (Top.isNotEmpty()) {
|
||||
println("高频 methodId:" + Top.joinToString(",") { "${it.key}×${it.value}" })
|
||||
}
|
||||
// 开发用:把解出的物品目录落成 JSON fixture,供 UI(React 车库)离线开发。
|
||||
val CatalogOut = js("process.env.TANKIOFFLINE_CATALOG_OUT || ''") as String
|
||||
if (CatalogOut.isNotEmpty()) {
|
||||
WriteCatalog(CatalogOut, Router)
|
||||
println("目录 fixture:$CatalogOut")
|
||||
}
|
||||
|
||||
// 抓包目录里混有入口/握手帧,解不出记录是正常现象;只要所有解出的记录都能回编码即可。
|
||||
val Passed = Bad == 0 && Records > 0 && Router.Catalog.Items.isNotEmpty() && UserOk
|
||||
println(if (Passed) "结果:通过" else "结果:失败")
|
||||
ExitSession(if (Passed) 0 else 1)
|
||||
}
|
||||
|
||||
private fun JsonEsc(S: String): String = S
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
|
||||
/** 把目录写成 UI fixture(只保留车库卡片需要的字段)。 */
|
||||
private fun WriteCatalog(Path: String, Router: SessionRouter) {
|
||||
val B = StringBuilder()
|
||||
B.append("{\"items\":[")
|
||||
var First = true
|
||||
for ((Id, Item) in Router.Catalog.Items) {
|
||||
if (!First) B.append(',')
|
||||
First = false
|
||||
val Name = (Item.description as? Net.Generated.DescriptionModelCC)?.Name ?: ""
|
||||
val Rarity = Item.rarity as? Int ?: 0
|
||||
B.append("{\"id\":").append(Id)
|
||||
B.append(",\"name\":\"").append(JsonEsc(Name)).append('"')
|
||||
B.append(",\"category\":").append(Item.itemCategory)
|
||||
B.append(",\"viewCategory\":").append(Item.itemViewCategory)
|
||||
B.append(",\"rarity\":").append(Rarity)
|
||||
B.append(",\"premium\":").append(if (Item.isPremium) "true" else "false")
|
||||
B.append(",\"count\":").append(Item.count)
|
||||
B.append(",\"buyable\":").append(if (Item.buyable) "true" else "false")
|
||||
B.append(",\"price\":").append(Router.Catalog.Prices[Id] ?: 0)
|
||||
B.append(",\"currency\":").append(Router.Catalog.Currencies[Id] ?: 0)
|
||||
B.append('}')
|
||||
}
|
||||
B.append("]}")
|
||||
val Write: (String, String) -> Unit =
|
||||
js("(function (P, T) { module.require('fs').writeFileSync(P, T, 'utf8'); })")
|
||||
Write(Path, B.toString())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"items":[{"id":1931009789024,"name":"史诗幸运礼盒","category":0,"viewCategory":0,"rarity":0,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009789028,"name":"普通幸运礼盒","category":0,"viewCategory":0,"rarity":0,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009789026,"name":"传奇幸运礼盒","category":0,"viewCategory":0,"rarity":0,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009789030,"name":"稀有幸运礼盒","category":0,"viewCategory":0,"rarity":0,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":-2147482397,"name":"","category":0,"viewCategory":0,"rarity":0,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":-2147482396,"name":"","category":1,"viewCategory":0,"rarity":0,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":-2147482006,"name":"","category":2,"viewCategory":0,"rarity":0,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":-2147482072,"name":"","category":14,"viewCategory":0,"rarity":0,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":-2147482393,"name":"","category":17,"viewCategory":0,"rarity":0,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":10007274,"name":"维修工具","category":3,"viewCategory":3,"rarity":5,"premium":false,"count":0,"buyable":true,"price":150,"currency":0},{"id":10007268,"name":"护甲提升","category":3,"viewCategory":3,"rarity":5,"premium":false,"count":94,"buyable":true,"price":50,"currency":0},{"id":920004863750,"name":"金箱子","category":3,"viewCategory":3,"rarity":5,"premium":false,"count":0,"buyable":true,"price":150,"currency":2},{"id":10007271,"name":"伤害提升","category":3,"viewCategory":3,"rarity":5,"premium":false,"count":0,"buyable":true,"price":50,"currency":0},{"id":10007277,"name":"地雷","category":3,"viewCategory":3,"rarity":5,"premium":false,"count":0,"buyable":true,"price":50,"currency":0},{"id":921009756684,"name":"核能","category":3,"viewCategory":3,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009780088,"name":"耀斑","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009780012,"name":"炸弹","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":38,"buyable":true,"price":1250,"currency":0},{"id":1931009780146,"name":"沙皇","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009780084,"name":"医护兵","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":true,"price":1250,"currency":0},{"id":1931009780145,"name":"雪球","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009780175,"name":"迫击炮","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":true,"price":1250,"currency":0},{"id":1931009780154,"name":"礼炮","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":true,"price":1800,"currency":0},{"id":1931009780221,"name":"蛋","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009780087,"name":"磁铁","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009780085,"name":"烟雾","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009780086,"name":"星辰","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009780083,"name":"泡沫","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009780126,"name":"南瓜","category":18,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":10007280,"name":"速度提升","category":3,"viewCategory":3,"rarity":5,"premium":false,"count":0,"buyable":true,"price":50,"currency":0},{"id":1931009781093,"name":"精英通行证","category":4,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":920009600251,"name":"解锁星级挑战奖励加成","category":4,"viewCategory":9,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":920006040680,"name":"军团许可证","category":12,"viewCategory":5,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009782002,"name":"活动通行证","category":4,"viewCategory":5,"rarity":5,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":1931009801939,"name":"狂乱","category":2,"viewCategory":2,"rarity":0,"premium":false,"count":0,"buyable":false,"price":0,"currency":0},{"id":920009180238,"name":"协同","catLine truncated
|
||||
@@ -289,3 +289,120 @@ img { display: block; }
|
||||
.nav span { display: none; }
|
||||
.nav { justify-content: center; }
|
||||
}
|
||||
|
||||
/* ---------------- React 车库 ---------------- */
|
||||
.garage-host { height: 100%; min-height: 0; }
|
||||
.garage {
|
||||
display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0;
|
||||
}
|
||||
.garage-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.garage-head h2 { margin: 0; font: 600 22px/1.2 BaseFontMedium, sans-serif; }
|
||||
.garage-head h2 small { margin-left: 8px; color: var(--muted); font-size: 12px; font-weight: 400; }
|
||||
.garage-search {
|
||||
width: 240px; padding: 8px 12px; border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,.14); background: rgba(3,10,16,.72); color: #fff; outline: none;
|
||||
}
|
||||
.garage-search:focus { border-color: rgba(255,179,0,.6); }
|
||||
.garage-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.garage-tab {
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
padding: 6px 12px; border-radius: 5px; font-size: 12px;
|
||||
border: 1px solid rgba(255,255,255,.12); background: rgba(4,12,20,.62); color: #c7d7e6;
|
||||
}
|
||||
.garage-tab span { color: var(--muted); font-size: 10px; }
|
||||
.garage-tab:hover { border-color: rgba(255,255,255,.3); }
|
||||
.garage-tab.active {
|
||||
border-color: #8a5d00; color: #241800; font-weight: 700;
|
||||
background: linear-gradient(180deg, #ffc93d, #f29500);
|
||||
}
|
||||
.garage-tab.active span { color: #4a3200; }
|
||||
.garage-main { flex: 1; min-height: 0; display: flex; gap: 14px; }
|
||||
.garage-grid {
|
||||
flex: 1; min-width: 0; overflow: auto; align-content: start;
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(132px, 1fr)); gap: 8px; padding-right: 4px;
|
||||
}
|
||||
.garage-card {
|
||||
position: relative; display: flex; flex-direction: column; align-items: stretch;
|
||||
padding: 0; border-radius: 7px; overflow: hidden; text-align: left;
|
||||
border: 1px solid rgba(255,255,255,.10);
|
||||
border-top: 2px solid var(--rarity, #9aa3b2);
|
||||
background: linear-gradient(180deg, rgba(20,34,48,.92), rgba(6,12,18,.94));
|
||||
color: var(--text);
|
||||
}
|
||||
.garage-card:hover { border-color: var(--rarity, #9aa3b2); transform: translateY(-1px); }
|
||||
.garage-card.selected { box-shadow: 0 0 0 1px var(--rarity, #ffb300), 0 0 18px rgba(255,179,0,.18); }
|
||||
.garage-card-art {
|
||||
height: 64px; display: flex; align-items: center; justify-content: center;
|
||||
font: 800 26px/1 BaseFontBold, sans-serif; color: var(--rarity, #9aa3b2);
|
||||
background: radial-gradient(closest-side, color-mix(in srgb, var(--rarity, #9aa3b2) 38%, transparent), transparent 72%);
|
||||
}
|
||||
.garage-card-name {
|
||||
padding: 0 8px; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.garage-card-meta {
|
||||
padding: 2px 8px 8px; display: flex; align-items: center; justify-content: space-between; gap: 6px;
|
||||
color: var(--muted); font-size: 10px;
|
||||
}
|
||||
.garage-price { color: #ffd76a; }
|
||||
.garage-premium {
|
||||
position: absolute; top: 5px; right: 5px; padding: 0 5px; border-radius: 3px;
|
||||
background: var(--accent); color: #241800; font: 800 9px/14px BaseFontBold, sans-serif;
|
||||
}
|
||||
.garage-empty { grid-column: 1 / -1; padding: 40px; text-align: center; color: var(--muted); }
|
||||
.garage-detail {
|
||||
width: 300px; flex: 0 0 300px; display: flex; flex-direction: column; gap: 8px;
|
||||
padding: 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,.10);
|
||||
background: rgba(6,14,22,.78);
|
||||
}
|
||||
.garage-detail h3 { margin: 0 0 4px; font: 600 17px/1.3 BaseFontMedium, sans-serif; }
|
||||
.garage-preview {
|
||||
height: 190px; border-radius: 6px; border: 1px solid rgba(255,255,255,.08);
|
||||
background:
|
||||
radial-gradient(60% 60% at 50% 45%, rgba(63,159,224,.16), transparent 70%),
|
||||
linear-gradient(180deg, rgba(255,255,255,.04), transparent);
|
||||
}
|
||||
.garage-preview-label { color: var(--muted); font-size: 11px; text-align: center; margin-top: -4px; }
|
||||
.garage-props { display: flex; flex-direction: column; gap: 5px; margin-top: 8px; }
|
||||
.garage-prop {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 5px 8px; border-radius: 4px; background: rgba(255,255,255,.03); font-size: 12px;
|
||||
}
|
||||
.garage-prop span { color: var(--muted); }
|
||||
.garage-prop b { font-weight: 600; }
|
||||
|
||||
/* ---------------- 大厅浮动元素 ---------------- */
|
||||
.lobby-chat {
|
||||
position: absolute; left: 118px; bottom: 34px; width: 370px; z-index: 5;
|
||||
padding: 9px 10px; border-radius: 8px;
|
||||
border: 1px solid rgba(255,255,255,.08);
|
||||
background: rgba(3,9,14,.72); backdrop-filter: blur(4px);
|
||||
}
|
||||
.chat-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; font-size: 12px; }
|
||||
.lobby-chat .chat-box { height: 148px; }
|
||||
.lobby-chat .chat-input { margin-top: 6px; }
|
||||
|
||||
.play-fab {
|
||||
position: absolute; right: 42px; bottom: 46px; z-index: 6;
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 14px 34px 14px 24px; border-radius: 4px;
|
||||
border: 1px solid #8a5d00;
|
||||
background: linear-gradient(180deg, #ffc93d, #ef9100);
|
||||
color: #241800; font: 800 22px/1 BaseFontBold, sans-serif;
|
||||
box-shadow: 0 10px 28px rgba(0,0,0,.45), 0 0 24px rgba(255,179,0,.25);
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
.play-fab img { width: 34px; height: 34px; }
|
||||
.play-fab:hover { filter: brightness(1.06); }
|
||||
|
||||
/* 稀有度颜色(React 车库卡片) */
|
||||
.rarity-0 { --rarity: #9aa3b2; }
|
||||
.rarity-1 { --rarity: #5fbf6a; }
|
||||
.rarity-2 { --rarity: #3f8fe0; }
|
||||
.rarity-3 { --rarity: #a05fe0; }
|
||||
.rarity-4 { --rarity: #e0a13f; }
|
||||
.rarity-5 { --rarity: #e05f5f; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.lobby-chat { display: none; }
|
||||
.play-fab { right: 18px; bottom: 24px; }
|
||||
}
|
||||
+6328
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user