feat(ui): 接通 Kotlin/React,迁移车库界面并导出物品目录 fixture

This commit is contained in:
TermiNexus committed 2026-10-25 19:25:47 +08:00
1 parent 6c1e8efb49
commit aa3903e92c
14 files changed
+6868 -37

No files matched your search

+9
View File
@@ -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 -1
View File
@@ -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/ 是代码生成产物,但要入库并允许手改,
+4
View File
@@ -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; }
}
File diff suppressed because it is too large. Load diff