feat(ui): 按线上 DOM 重建大厅首页与车库布局

This commit is contained in:
TermiNexus committed 2026-10-25 19:45:13 +08:00
1 parent 7b5738187a
commit 7923c60f98
4 files changed
+834 -704

No files matched your search

+56 -2
View File
@@ -220,11 +220,65 @@ TankiOffline (Kotlin/JS 客户端)
- 资源:本地镜像已接入。
- 已确认:技术栈 Kotlin/React、组件级还原、本地 mock、3D 现在做、首批大厅+车库、允许混跑。
## 8. 实现进度(滚动更新)
## 9. 原版 DOM 实测映射(2026-10-25,登录 WYT2WYT 后)
视口 1296×1006,根字号 12.96px(`.ksc-37` 的 `max(min(1.48148vh,1vw),3px)`)。
所有尺寸换算:1em ≈ 12.96px。
### 9.1 大厅首页
| 原 DOM 类 | 位置 / 尺寸 | 含义 |
|---|---|---|
| `MainScreenComponentStyle-containerPanel` | 0,0 1296×78 | 顶栏(6em,底边 1px 白 25%) |
| `UserInfoContainerStyle-blockLeftPanel` | 0,0 408×78 | 等级图标 / 组队图标 / 昵称 / XP |
| `UserInfoContainerStyle-rankIconContainerClickable` | 155,0 78×78 | 等级图标(`static/images/<rank>.<hash>.webp`) |
| `UserInfoContainerStyle-containerProgressMainScreen` | 246,15 162×49 | 昵称 + XP 条 + 数值 |
| `UserScoreComponentStyle-blockRightPanel` | 1017,0 279×78 | 两行货币(红宝石 / 水晶)+ 全屏 + 退出 |
| `MainScreenComponentStyle-containerForMenuGradient` | 0,0 648×1006 | 左侧 50%,黑→透明 0.5 |
| `MainScreenComponentStyle-playButtonContainer` | 57,130 284×139 | “开始战斗”,背景 `videoplay.79570900.gif` |
| `MainScreenComponentStyle-blockMainMenu` | 57,283 324×645 | 菜单容器 |
| `PrimaryMenuItemComponentStyle-menuItemContainer` | 65,283 350×58 | 菜单项:礼盒 / 任务 / 车库 / 商店 / 设置 |
| `PrimaryMenuItemComponentStyle-itemCommonLi` | 73,290 45×45 | 图标(mask + `#bfd5ff`) |
| `PrimaryMenuItemComponentStyle-itemName` | 133,293 | 2em 大写文字 |
| `FooterComponentStyle-footer` | 0,928 648×78 | 新闻 / 聊天 / 好友 / 军团(65×65) |
| `MainScreenComponentStyle-gradientBlockRight` | 936,0 360×1006 | 右侧渐变(27.75em,0.75,旋转 180°) |
| `BattlePassLobbyComponentStyle-menuBattlePass` | 972,110 292×130 | 星级挑战面板(22.5em×10em,right 2.5em) |
| `AnnouncementHomeScreenComponentStyle-mainContainer` | 971,778 293×196 | 公告卡(22.5em 宽,right/bottom 2.5em) |
| `ClientInfoComponentStyle-main` | 0,989 1296×17 | UID / 版本 / 电池 |
### 9.2 车库
| 原 DOM 类 | 位置 / 尺寸 | 含义 |
|---|---|---|
| `BreadcrumbsComponentStyle-headerContainer` | 0,0 1296×78 | 车库顶栏(返回 / 标题 / 货币 / 退出) |
| `BreadcrumbsComponentStyle-backButton` | 0,0 78×78 | 返回 |
| `BreadcrumbsComponentStyle-rootTitle` | 585,2 126×73 | 当前屏幕标题 |
| `GarageCommonStyle-garageContainer` | 0,78 1296×928 | 车库主体 |
| `GarageCommonStyle-positionContent` | 0,130 1296×876 | 内容区 |
| `GarageMainScreenStyle-blockParameters` | 78,130 1140×685 | 中央 3D 预览 + 左右参数 |
| `TankParametersStyle-leftParamsContainer` | 78,130 293×209 | 左上:GS + 参数 |
| `MountedItemsStyle-container` | 920,130 298×492 | 右上:无人机 / 手榴弹 / 炮塔 / 底盘 |
| `MountedItemsStyle-resistanceContainer` | 78,740 52×65 ×4 | 左下:防御模块 |
| `SuppliesComponentStyle-commonBlockSupplies` | 78,862 1140×125 | 底部道具栏 |
| `TankPreviewComponentStyle-visible GarageComponentStyle-tankPreview` | 0,0 1296×1006 | 3D 坦克画布 |
### 9.3 对应关系(JS → DOM)
- `82347` 里 `Sq(Class, "XxxComponentStyle", ...)` 注册的样式类,就是 DOM 上的 `XxxComponentStyle-*`。
- `Eq("containerPanel", 1, ...)` 返回的类名拼接为 `MainScreenComponentStyle-containerPanel`。
- 样式值直接写在 `jq(Class).xxx = function(){ return this.<slot>.dk(this, Eq("...")) }` 的样式对象里;
运行时可从 `document.styleSheets` 按类名前缀抓取(已用于本表)。
- 组件层级:`MainScreen` → `Header` / `PrimaryMenu` / `Footer` / `BattlePass` / `Announcement`;
车库:`GarageCommon` → `TankParameters` / `MountedItems` / `Supplies` / `TankPreview`。
### 9.4 当前实现状态
- 大厅首页已按上表重建(6em 顶栏、21.875em Play、27em 菜单、5em 底栏、22.5em 右栏)。
- 车库正在按 9.2 重建:左侧参数 / 中央 3D / 右侧已装配件 / 底部道具栏;
物品选择列表作为抽屉保留,后续替换为原版的分类轮盘。
- [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 预览加载器。
- [ ] 车库按 9.2 的原版布局重建 + 3D 预览加载器。
- [ ] 协议级 mock 服务器(用户 / 大厅列表 / 任务)。
- [ ] 大厅其余屏幕迁移到 React。
@@ -1,74 +1,62 @@
// LobbyView.kt —— 真实大厅 UI(接入 RootState / 物品目录 / 官方文案与图标)
// LobbyView.kt —— 大厅主界面(按线上 DOM/CSS 复刻)
//
// 目标是把「诊断页」换成可持续扩展的大厅骨架:
// 顶栏(等级 / GS / 水晶 / 金币 / VIP)+ 左侧官方菜单 + 主内容区 + 右侧聊天/公告 + 底部连接状态。
// 文案运行时从游戏自带的 zh-CN 翻译拉取(Translations.kt),图标用 resources.json 里的官方资源路径。
// 车库 / 商店 / 幸运礼盒直接读 ItemCatalog(抓包解出的 1399 物品 + 516 装置)。
// 结构对照 3dtank.com/play/ 登录后的真实 DOM:
// canvas(3D 车库场景)
// MainScreenComponentStyle-containerPanel(顶栏 6em = 78px)
// UserInfoContainerStyle-blockLeftPanel(等级图标 / 昵称 / XP)
// UserScoreComponentStyle-blockRightPanel(红宝石 / 水晶 / 全屏 / 退出)
// MainScreenComponentStyle-containerForMenuGradient(左侧 50% 渐变区)
// playButtonContainer(21.875em × 10.75em,videoplay.gif)
// blockMainMenu(幸运礼盒 / 任务 / 车库 / 商店 / 设置)
// FooterComponentStyle-footer(新闻 / 聊天 / 好友 / 军团)
// MainScreenComponentStyle-gradientBlockRight(右侧 27.75em)
// BattlePassLobbyComponentStyle-menuBattlePass(22.5em × 10em)
// AnnouncementHomeScreenComponentStyle-mainContainer(22.5em 宽)
// ClientInfoComponentStyle-main(底部 UID / 版本)
// 根字号 12.96px(原 .ksc-37),所有尺寸用 em 与线上一致。
package App.Browser
import App.Browser.Ui.GarageContext
import App.Browser.Ui.GarageMount
import App.Browser.Ui.UiCatalog
import App.State.GarageItemObject
import Net.Generated.DescriptionModelCC
import App.State.Reducers.UserRankBounds
private val Doc: dynamic = js("document")
private val LDoc: dynamic = js("document")
/** 官方静态资源前缀(资源路径来自 resources.json)。 */
private const val AssetRoot = "/play/"
private const val LAssetRoot = "/play/"
private fun Asset(Path: String): String = AssetRoot + Path
private fun LAsset(Path: String): String = LAssetRoot + Path
private fun Esc(Text: String): String = Text
private fun LEsc(Text: String): String = Text
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;")
/** 车库分类:数值来自 module_85667 的 ItemCategory 枚举。 */
private val Categories: List<Triple<Int, String, String>> = listOf(
Triple(0, "WEAPON", "炮塔"),
Triple(1, "ARMOR", "底盘"),
Triple(2, "PAINT", "迷彩"),
Triple(3, "INVENTORY", "道具"),
Triple(5, "KIT", "套件"),
Triple(10, "RESISTANCE_MODULE", "防御模块"),
Triple(11, "DEVICE", "装置"),
Triple(13, "CONTAINER", "幸运礼盒"),
Triple(14, "DRONE", "辅助无人机"),
Triple(15, "SKIN", "皮肤"),
Triple(17, "BAZOOKA", "火箭炮"),
Triple(18, "GRENADE", "手榴弹"),
)
private val RarityColors = listOf("#9aa3b2", "#5fbf6a", "#3f8fe0", "#a05fe0", "#e0a13f", "#e05f5f")
private fun ItemName(Item: GarageItemObject): String {
val D = Item.description as? DescriptionModelCC
return D?.Name ?: "物品 ${Item.id}"
}
private fun ItemRarity(Item: GarageItemObject): Int = Item.rarity as? Int ?: 0
private fun ItemCategory(Item: GarageItemObject): Int = Item.itemCategory
private fun CategoryLabel(Category: Int): String {
val Entry = Categories.firstOrNull { it.first == Category } ?: return "其它"
return Translations.T("lobby.garage.${Entry.second}", Entry.third)
/** 等级图标:10–31 级有官方资源;低等级回退到 10 级图标。 */
private fun RankIcon(Rank: Int): String {
val R = if (Rank in 10..31) Rank else 10
val Hash = mapOf(
10 to "17447044", 11 to "f4ddcab2", 12 to "c8158aa7", 13 to "2baa3518",
14 to "1fcc1281", 15 to "eaafa648", 16 to "9d0599ee", 17 to "dd67d583",
18 to "e86c45fa", 19 to "bc7f519b", 20 to "76063620", 21 to "cef08a4f",
22 to "0bc84156", 23 to "3c31b410", 24 to "e5342906", 25 to "ef416fe8",
26 to "128b1504", 27 to "c9a72f49", 28 to "b96f89b9", 29 to "ee5f90ba",
30 to "5c2d92a0", 31 to "91cbb062",
)[R] ?: "eaafa648"
return LAsset("static/images/$R.$Hash.webp")
}
class LobbyView(private val Client: LobbyClient) {
private var Nav: String = "home"
private var Category: Int = 0
private var Query: String = ""
private var Mounted = false
private var GarageMounted = false
fun Mount() {
if (Mounted) return
Mounted = true
val Root = Doc.getElementById("app")
Root.innerHTML = Shell()
LDoc.getElementById("app").innerHTML = Shell()
Bind()
Update()
}
@@ -77,160 +65,162 @@ class LobbyView(private val Client: LobbyClient) {
if (!Mounted) return
val State = Client.Router.Store.State
val U = State.User
val Bounds = U.RankBounds as? UserRankBounds ?: UserRankBounds()
val Low = Bounds.LowBound
val Top = if (Bounds.TopBound > 0) Bounds.TopBound else Low + 1
val Ratio = ((U.Score - Low).toDouble() / (Top - Low).coerceAtLeast(1)).coerceIn(0.0, 1.0)
SetHtml(
"lobby-stats",
"ms-top-left",
"""
<div class="user-block">
<div class="rank-badge">${U.Rank}</div>
<div class="user-meta">
<b>${Esc(U.Uid.ifEmpty { "未登录" })}</b>
<span>GS ${U.GearScore} · Rank ${U.Rank}</span>
<div class="rank-icon"><img src="${RankIcon(U.Rank)}" alt=""></div>
<div class="user-progress">
<span class="user-name">${LEsc(U.Uid.ifEmpty { "未登录" })}</span>
<div class="xp-bar"><i style="width:${(Ratio * 100).toInt()}%"></i></div>
<div class="xp-text"><i class="xp-icon"></i>${U.Score}<span> / ${if (Bounds.TopBound > 0) Top else "—"}</span></div>
</div>
"""
)
SetHtml(
"ms-top-right",
"""
<div class="ms-currencies">
<div class="ms-currency">
<img class="coin" src="${LAsset("static/images/ruby.ce62efbc.svg")}" alt="">
<span>${U.Rubies}</span>
<img class="plus" src="${LAsset("static/images/addRuby.ff47c8be.svg")}" alt="">
</div>
<div class="ms-currency">
<img class="coin" src="${LAsset("static/images/crystalSmall.242e6c15.svg")}" alt="">
<span>${U.Crystals}</span>
<img class="plus" src="${LAsset("static/images/addCrystal.af9a397c.svg")}" alt="">
</div>
</div>
<div class="currencies">
<span class="stat" title="水晶"><img src="${Asset("static/images/crystalSmall.242e6c15.svg")}">${U.Crystals}</span>
<span class="stat" title="坦克币"><img src="${Asset("static/images/gold.e0b82d44.svg")}">${U.Score}</span>
<span class="stat ${if (U.HasPremium) "premium" else "muted"}" title="VIP">
<img src="${Asset("static/images/premiumIcon.e8725ab0.svg")}">${if (U.HasPremium) "VIP" else "普通"}
</span>
</div>
<div class="ms-fullscreen"><i></i><b>F11</b></div>
<button class="ms-logout" data-go="logout"><i></i></button>
"""
)
if (Nav != "garage" && GarageMounted) {
GarageMount.Unmount()
GarageMounted = false
}
if (!(Nav == "garage" && GarageMounted)) {
LDoc.getElementById("lobby-content").classList.toggle("hidden", Nav == "home")
LDoc.getElementById("ms-home").classList.toggle("hidden", Nav != "home")
LDoc.getElementById("ms-top").classList.toggle("hidden", Nav != "home")
LDoc.getElementById("ms-back").classList.toggle("hidden", Nav == "home" || Nav == "garage")
LDoc.getElementById("ms-back").textContent = "← 返回"
if (Nav == "home") {
if (GarageMounted) {
GarageMount.Unmount()
GarageMounted = false
}
SetHtml("lobby-content", "")
} else if (!(Nav == "garage" && GarageMounted)) {
SetHtml("lobby-content", Content())
if (Nav == "garage" && UiCatalog.Loaded) {
GarageContext.Uid = U.Uid
GarageContext.Rank = U.Rank
GarageContext.GearScore = U.GearScore
GarageContext.Crystals = U.Crystals
GarageContext.Rubies = U.Rubies
GarageContext.OnBack = {
Nav = "home"
Update()
}
GarageMount.Mount(UiCatalog.Items)
GarageMounted = true
}
}
if (Nav == "garage" && UiCatalog.Loaded) {
GarageMount.Mount(UiCatalog.Items)
GarageMounted = true
}
SetHtml("lobby-chat", Chat())
SetHtml(
"lobby-status",
"${Client.Status}|帧 ${Client.Frames}|记录 ${Client.Records}|已派发 ${Client.Router.Applied}" +
"|未覆盖 ${Client.Router.Unmapped}|目录 ${Client.Router.Catalog.Items.size} 物品 / " +
"${Client.Router.Catalog.Devices.size} 装置|屏幕 ${State.Screen.Current}"
"client-info",
"UID: ${LEsc(U.Uid.ifEmpty { "-" })}<i>|</i>TYPE: web<i>|</i>VER: Kotlin/JS<i>|</i>" +
"SRV: offline<i>|</i>STATE: ${LEsc(Client.Status)}"
)
BindDynamic()
}
// ------------------------------------------------------------------ 骨架
private fun Shell(): String = """
<div class="lobby">
<div class="lobby-bg"></div>
<header class="topbar">
<div id="lobby-stats" class="stats"></div>
<button id="connect-toggle" class="connect-toggle">连接</button>
<div class="ms-root">
<canvas id="lobby-canvas"></canvas>
<div class="ms-bg"></div>
<header id="ms-top" class="ms-top">
<div id="ms-top-left" class="ms-top-left"></div>
<div class="ms-top-logo"><img src="${LAsset("static/images/logoTankiOnlineChina.0b6ba850.svg")}" alt=""></div>
<div id="ms-top-right" class="ms-top-right"></div>
</header>
<div class="lobby-body">
<nav class="menu">
${NavButton("battles", "iconLobbyMenuWar.ada9b0af.svg", "开始战斗", "BATTLE")}
${NavButton("garage", "iconLobbyMenuGarage.0b623165.svg", "车库", "GARAGE")}
${NavButton("quests", "iconLobbyMenuQuests.54801b8b.svg", "任务", "QUESTS")}
${NavButton("shop", "iconLobbyMenuStore.e755fe14.svg", "商店", "SHOP")}
${NavButton("containers", "iconLobbyMenuContainers.f960290f.svg", "幸运礼盒", "CONTAINERS")}
${NavButton("clans", "iconLobbyMenuClan.f01ae646.svg", "军团", "CLANS")}
${NavButton("friends", "Add-friend.137d3601.svg", "好友", "FRIENDS")}
${NavButton("news", "iconLobbyMenuNews.6efe9ae3.svg", "新闻", "NEWS")}
${NavButton("settings", "iconLobbyMenuSettings.cc00d08a.svg", "设置", "OPTIONS")}
</nav>
<div id="ms-home" class="ms-home">
<div class="ms-left-gradient">
<button class="ms-play" data-go="battles">
<span>${LEsc(Translations.T("lobby.menu.PLAY", "开始战斗"))}</span>
</button>
<ul class="ms-menu">
${MenuItem("containers", "Containers", "幸运礼盒")}
${MenuItem("quests", "Quests", "任务")}
${MenuItem("garage", "Garage", "车库")}
${MenuItem("shop", "Shop", "商店")}
${MenuItem("settings", "Option", "设置")}
</ul>
<footer class="ms-footer">
<ul>
${FooterItem("news", "News")}
${FooterItem("chat", "Chat")}
${FooterItem("friends", "Friends")}
${FooterItem("clans", "Clan")}
</ul>
</footer>
</div>
<main id="lobby-content" class="content"></main>
</div>
<div id="lobby-chat" class="lobby-chat"></div>
<button class="play-fab" data-go="battles">
<img src="${Asset("static/images/join_battle.b0903eb3.svg")}" alt="">
<span>${Esc(Translations.T("lobby.menu.PLAY", "开始战斗"))}</span>
</button>
<footer class="statusbar"><span id="lobby-status">未连接</span></footer>
<div id="connect-panel" class="connect-panel hidden">
<h3>连接中继</h3>
<label>大厅地址<input id="lobby-url" spellcheck="false"></label>
<label>空间地址<input id="space-url" spellcheck="false"></label>
<div class="connect-actions">
<button id="connect-go" class="primary">连接</button>
<button id="connect-close">关闭</button>
<div class="ms-right-gradient"></div>
<div class="ms-battle-pass">
<div class="ms-bp-head">
<span><img src="${LAsset("static/images/star.01f7e4f8.svg")}" alt="">${Client.Router.Store.State.Challenges.Stars.values.sum()}</span>
<span class="muted">星级挑战</span>
</div>
<div class="ms-bp-row"><i></i><div><b>等级 ${Client.Router.Store.State.Challenges.BattlePasses.size}</b><span class="muted">星级挑战</span></div></div>
<div class="ms-bp-row locked"><i></i><div><b>精英通行证</b><span class="muted">未解锁</span></div></div>
</div>
<div class="ms-announce">
<div class="ms-announce-art"></div>
<div class="ms-announce-title">${LEsc(Translations.T("lobby.menu.NEWS", "新闻"))}</div>
</div>
<p class="hint">默认 ws://127.0.0.1:9091/(本地 wss_server 中继)。也可用 ?lobby=…&space=… 预填。</p>
</div>
<button id="ms-back" class="ms-back hidden" data-go="home">← 返回</button>
<main id="lobby-content" class="ms-content hidden"></main>
<footer id="client-info" class="ms-client-info"></footer>
</div>
""".trimIndent()
private fun NavButton(Id: String, Icon: String, Label: String, Key: String): String = """
<button class="nav" data-nav="$Id">
<img src="${Asset("static/images/$Icon")}" alt="">
<span>${Esc(Translations.T("lobby.menu.$Key", Label))}</span>
</button>
private fun MenuItem(Id: String, Key: String, Fallback: String): String = """
<li class="ms-menu-item" data-go="$Id">
<div class="ms-menu-icon icon-$Id"></div>
<span>${LEsc(Translations.T("lobby.menu.${Key.uppercase()}", Fallback))}</span>
</li>
""".trimIndent()
private fun FooterItem(Id: String, Key: String): String = """
<li class="ms-footer-item" data-go="$Id">
<div class="ms-footer-icon icon-$Id"></div>
</li>
""".trimIndent()
private fun Bind() {
Doc.querySelectorAll(".nav").forEach { El: dynamic ->
LDoc.querySelectorAll("[data-go]").forEach { El: dynamic ->
El.addEventListener("click", {
Nav = El.getAttribute("data-nav") as String
Update()
})
}
Doc.getElementById("connect-toggle").addEventListener("click", {
val Panel = Doc.getElementById("connect-panel")
Panel.classList.toggle("hidden")
})
Doc.getElementById("connect-close").addEventListener("click", {
Doc.getElementById("connect-panel").classList.add("hidden")
})
Doc.getElementById("connect-go").addEventListener("click", {
val Lobby = (Doc.getElementById("lobby-url").value as String).trim()
val Space = (Doc.getElementById("space-url").value as String).trim()
if (Lobby.isEmpty()) {
Client.Log("✗ 大厅地址不能为空")
} else {
Client.Connect(Lobby, if (Space.isEmpty()) Lobby else Space)
Doc.getElementById("connect-panel").classList.add("hidden")
}
})
val LobbyInput = Doc.getElementById("lobby-url")
val SpaceInput = Doc.getElementById("space-url")
val Params = js("new URLSearchParams(window.location.search)")
val LobbyDefault = (Params.get("lobby") as? String) ?: "ws://127.0.0.1:9091/"
val SpaceDefault = (Params.get("space") as? String) ?: LobbyDefault
LobbyInput.value = LobbyDefault
SpaceInput.value = SpaceDefault
if (Params.get("autoconnect") == "1") {
Client.Connect(LobbyDefault, SpaceDefault)
}
}
private fun BindDynamic() {
Doc.querySelectorAll(".nav").forEach { El: dynamic ->
val Id = El.getAttribute("data-nav") as String
El.classList.toggle("active", Id == Nav)
}
Doc.querySelectorAll("[data-garage-cat]").forEach { El: dynamic ->
El.addEventListener("click", {
Category = (El.getAttribute("data-garage-cat") as String).toInt()
Query = ""
Update()
})
}
Doc.querySelectorAll("[data-go]").forEach { El: dynamic ->
El.addEventListener("click", {
Nav = El.getAttribute("data-go") as String
Update()
})
}
val Search = Doc.getElementById("garage-search")
if (Search != null) {
Search.addEventListener("input", {
Query = (Search.value as String)
Refresh()
val Target = El.getAttribute("data-go") as String
if (Target == "logout") {
Client.Disconnect()
js("window.location.reload()")
} else {
Nav = Target
if (Target == "garage" && !UiCatalog.Loaded) {
UiCatalog.Ensure(Client) { Refresh() }
}
Update()
}
})
}
}
@@ -255,180 +245,95 @@ class LobbyView(private val Client: LobbyClient) {
"containers" -> Containers()
"battles" -> Battles()
"quests" -> Quests()
"clans" -> Placeholder("军团", "军团系统将在 S4 之后接入。")
"friends" -> Friends()
"chat" -> Chat()
"news" -> News()
"clans" -> Placeholder("军团", "军团系统将在 S4 之后接入。")
"friends" -> Placeholder("好友", "好友列表将在社交链路补齐后接入。")
"settings" -> Settings()
else -> Home()
}
private fun Home(): String {
val Connected = Client.Status == "已连接"
return """
<section class="hero">
<div class="hero-copy">
<div class="eyebrow">${Esc(Translations.T("lobby.menu.PLAY", "开始战斗"))}</div>
<h1>${Esc(Translations.T("lobby.menu.PLAY", "开始战斗"))}</h1>
<p>快速匹配一场战斗,或从战斗列表中选择模式与地图。</p>
<div class="hero-actions">
<button class="primary big" data-go="battles">${Esc(Translations.T("lobby.battle.pick.SEARCH_BATTLE", "搜索一场战斗"))}</button>
<button class="ghost big" data-go="garage">${Esc(Translations.T("lobby.menu.GARAGE", "车库"))}</button>
</div>
${if (Connected) "" else """
<p class="warn">当前未连接服务器:点右上角「连接」填入中继地址后即可接收真实大厅数据。</p>
"""}
</div>
<div class="hero-art"></div>
</section>
<div class="cards">
${HomeCard("quests", "iconLobbyMenuQuests.54801b8b.svg", Translations.T("lobby.menu.QUESTS", "任务"), "每日 / 每周任务与星级挑战")}
${HomeCard("shop", "iconLobbyMenuStore.e755fe14.svg", Translations.T("lobby.menu.SHOP", "商店"), "水晶 / 坦克币 / 礼盒")}
${HomeCard("garage", "iconLobbyMenuGarage.0b623165.svg", Translations.T("lobby.menu.GARAGE", "车库"), "装备、皮肤与强化")}
</div>
""".trimIndent()
}
private fun HomeCard(Nav: String, Icon: String, Title: String, Text: String): String = """
<button class="card" data-go="$Nav">
<img src="${Asset("static/images/$Icon")}" alt="">
<div><b>${Esc(Title)}</b><span>${Esc(Text)}</span></div>
</button>
""".trimIndent()
private fun Battles(): String {
val Modes = listOf(
Triple("TEAM_MODE", "快速战斗", "快速加入战斗,系统将随机分配阵营与地图。"),
Triple("DEATHMATCH", "死亡混战", "竞技场中的每个玩家都是您的敌人。"),
Triple("CAPTURE_THE_FLAG", "互夺军旗", "夺取最多旗子的队伍获胜。"),
Triple("CONTROL_POINTS", "控制点", "占领敌方点位并阻止敌人占领。"),
Triple("JUGGERNAUT", "剑圣", "摧毁尽可能多的坦克。"),
Triple("SIEGE", "围城大战", "控制一个点并使敌人远离它。"),
)
return """
<section class="section-head"><h2>${Esc(Translations.T("lobby.battle.list.TITLE", "战场列表"))}</h2>
<button class="primary" data-go="garage">${Esc(Translations.T("lobby.menu.GARAGE", "车库"))}</button>
</section>
<div class="mode-grid">
${Modes.joinToString("") { (Key, Name, Desc) -> """
<button class="mode-card" data-go="battles">
<b>${Esc(Translations.T("lobby.battle.pick.${Key}_NAME", Name))}</b>
<span>${Esc(Translations.T("lobby.battle.pick.${Key}_DESCRIPTION", Desc))}</span>
</button>
""" }}
</div>
<p class="hint">战斗创建 / 匹配的完整链路属于 S4;这里先接真实状态里的战斗列表与模式文案。</p>
""".trimIndent()
else -> ""
}
private fun Garage(): String {
if (!UiCatalog.Loaded) {
UiCatalog.Ensure(Client) { Refresh() }
return """
<section class="section-head"><h2>${Esc(Translations.T("lobby.menu.GARAGE", "车库"))}</h2></section>
<div class="empty" id="garage-loading">正在加载物品目录…</div>
""".trimIndent()
return """<div class="ms-screen"><div class="ms-empty">正在加载物品目录…</div></div>"""
}
return """<section class="garage-host"><div id="garage-root"></div></section>"""
return """<div class="ms-screen ms-garage-screen"><div id="garage-root"></div></div>"""
}
private fun Shop(): String {
val All = Client.Router.Catalog.Items.values.filter { it.buyable }
val Shown = All.take(96)
return """
<section class="section-head"><h2>${Esc(Translations.T("lobby.menu.SHOP", "商店"))}
<small>${All.size} 件可购买</small></h2></section>
${if (All.isEmpty()) """
<div class="empty">离线目录里暂无可购买物品,连接服务器后会自动填充。</div>
""" else """
<div class="item-grid">${Shown.joinToString("") { ItemCard(it) }}</div>
"""}
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.menu.SHOP", "商店"))}<small>${All.size} 件可购买</small></h2>
<div class="ms-empty">${if (All.isEmpty()) "离线目录里暂无可购买物品。" else "商店卡片将在下一轮迁移。"}</div>
</div>
""".trimIndent()
}
private fun Containers(): String {
val All = Client.Router.Catalog.Items.values.filter { ItemCategory(it) == 13 }
val N = Client.Router.Catalog.Items.values.count { it.itemCategory == 13 }
return """
<section class="section-head"><h2>${Esc(Translations.T("lobby.menu.CONTAINERS", "幸运礼盒"))}
<small>${All.size}</small></h2></section>
${if (All.isEmpty()) "<div class=\"empty\">没有礼盒数据。</div>"
else "<div class=\"item-grid\">" + All.take(60).joinToString("") { ItemCard(it) } + "</div>"}
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.menu.CONTAINERS", "幸运礼盒"))}<small>$N</small></h2>
<div class="ms-empty">礼盒开启动画与掉落表待接入。</div>
</div>
""".trimIndent()
}
private fun Quests(): String = """
<section class="section-head"><h2>${Esc(Translations.T("lobby.quests.navigation.HEADER", "任务"))}</h2></section>
<div class="cards">
${HomeCard("quests", "iconLobbyMenuQuests.54801b8b.svg", Translations.T("lobby.quests.navigation.MAIN", "主要"), "主线任务")}
${HomeCard("quests", "iconLobbyMenuQuests.54801b8b.svg", Translations.T("lobby.quests.navigation.DAILY", "每日"), "每日任务")}
${HomeCard("quests", "iconLobbyMenuQuests.54801b8b.svg", Translations.T("lobby.quests.navigation.WEEKLY", "每周"), "每周任务")}
${HomeCard("quests", "iconLobbyMenuQuests.54801b8b.svg", Translations.T("lobby.quests.navigation.CHALLENGES", "星级挑战"), "星际挑战")}
private fun Battles(): String = """
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.battle.list.TITLE", "战场列表"))}</h2>
<div class="ms-empty">战斗选择 / 匹配属于 S4;当前先接入真实大厅外壳。</div>
</div>
""".trimIndent()
private fun Friends(): String = """
<section class="section-head"><h2>${Esc(Translations.T("lobby.friends.TITLE", "好友"))}</h2></section>
<div class="empty">好友列表将在社交链路补齐后接入。</div>
private fun Quests(): String = """
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.quests.navigation.HEADER", "任务"))}</h2>
<div class="ms-empty">任务列表将在下一轮迁移。</div>
</div>
""".trimIndent()
private fun Chat(): String {
val Lines = Client.Logs.takeLast(20).joinToString("") { "<div class=\"ms-log-line\">${LEsc(it)}</div>" }
return """
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.menu.CHAT", "聊天"))}</h2>
<div class="ms-chat-box">${if (Lines.isEmpty()) "<span class=\"muted\">连接后显示服务器日志</span>" else Lines}</div>
</div>
""".trimIndent()
}
private fun News(): String {
val Items = Client.Router.Store.State.News.News
return """
<section class="section-head"><h2>${Esc(Translations.T("lobby.menu.NEWS", "新闻"))}</h2></section>
${if (Items.isEmpty()) "<div class=\"empty\">暂无新闻。</div>"
else "<div class=\"news-list\">" + Items.take(20).joinToString("") { N ->
"<article class=\"news\"><b>${Esc(N.header)}</b><p>${Esc(N.description)}</p></article>"
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.menu.NEWS", "新闻"))}</h2>
${if (Items.isEmpty()) "<div class=\"ms-empty\">暂无新闻。</div>"
else "<div class=\"ms-news-list\">" + Items.take(20).joinToString("") { N ->
"<article class=\"ms-news\"><b>${LEsc(N.header)}</b><p>${LEsc(N.description)}</p></article>"
} + "</div>"}
</div>
""".trimIndent()
}
private fun Settings(): String = """
<section class="section-head"><h2>${Esc(Translations.T("lobby.menu.OPTIONS", "设置"))}</h2></section>
<div class="cards">
<div class="card static"><div><b>连接</b><span>大厅 / 空间中继地址</span></div></div>
<div class="card static"><div><b>语言</b><span>简体中文(运行时读取官方 zh-CN)</span></div></div>
<div class="card static"><div><b>诊断</b><span>帧 ${Client.Frames},记录 ${Client.Records},未覆盖 ${Client.Router.Unmapped}</span></div></div>
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Translations.T("lobby.menu.OPTIONS", "设置"))}</h2>
<div class="ms-empty">设置页将在下一轮迁移(游戏 / 画面 / 声音 / 操控 / 账户 …)。</div>
</div>
""".trimIndent()
private fun Placeholder(Title: String, Text: String): String = """
<section class="section-head"><h2>${Esc(Title)}</h2></section>
<div class="empty">${Esc(Text)}</div>
<div class="ms-screen">
<h2 class="ms-screen-title">${LEsc(Title)}</h2>
<div class="ms-empty">${LEsc(Text)}</div>
</div>
""".trimIndent()
private fun Chat(): String {
val Lines = Client.Logs.takeLast(12).joinToString("") { "<div class=\"log-line\">${Esc(it)}</div>" }
return """
<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>
""".trimIndent()
}
private fun ItemCard(Item: GarageItemObject): String {
val Rarity = ItemRarity(Item)
val Color = RarityColors.getOrElse(Rarity) { RarityColors[0] }
val Name = ItemName(Item)
val Category = CategoryLabel(ItemCategory(Item))
val Premium = Item.isPremium
val Count = Item.count
return """
<article class="item" style="--rarity:$Color">
<div class="item-art"><span>${Esc(Name.take(1))}</span></div>
<div class="item-body">
<b title="${Esc(Name)}">${Esc(Name)}</b>
<span class="muted">${Esc(Category)}${if (Count > 1) " · x$Count" else ""}</span>
${if (Premium) "<span class=\"badge\">VIP</span>" else ""}
</div>
</article>
""".trimIndent()
}
private fun SetHtml(Id: String, Html: String) {
val El = Doc.getElementById(Id) ?: return
val El = LDoc.getElementById(Id) ?: return
El.innerHTML = Html
}
}
@@ -1,7 +1,13 @@
// GarageScreen.kt —— React 车库(大厅第一个正式迁移的界面)
// GarageScreen.kt —— React 车库(按线上 GarageCommon / TankParameters / MountedItems / Supplies 布局)
//
// 数据:UiCatalog(真实 ItemCatalog 或抓包导出的 fixture)。
// 结构对照原版:分类页签 + 物品网格 + 右侧详情 / 3D 预览位。
// 线上结构(1296×1006):
// header 78px(返回 / 车库标题 / 货币 / 退出)
// GarageCommonStyle-garageContainer 928px
// 左:TankParametersStyle-leftParamsContainer(GS + 参数,293×209)
// 中:GarageMainScreenStyle-blockParameters(3D 坦克,1140×685)
// 右:MountedItemsStyle-container(无人机 / 手榴弹 / 炮塔 / 底盘,298×492)
// 底:SuppliesComponentStyle-commonBlockSupplies(1140×125)
// 当前 3D 用占位画布,物品选择先用抽屉保留目录浏览。
package App.Browser.Ui
import App.Browser.Translations
@@ -14,30 +20,41 @@ 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.header
import react.dom.html.ReactHTML.i
import react.dom.html.ReactHTML.img
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
/** 大厅把当前用户/回退动作放进这个上下文,React 组件直接读。 */
object GarageContext {
var Uid: String = ""
var Rank: Int = 1
var GearScore: Int = 0
var Crystals: Int = 0
var Rubies: Int = 0
var OnBack: () -> Unit = {}
}
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 fun Asset(Path: String): String = "/play/" + Path
private val GarageScreen = FC<Props> {
var Category by useState(-1)
var Query by useState("")
var Selected by useState<CatalogItem?>(null)
var Drawer by useState(false)
val All = UiCatalog.Items
val Counts = GarageCategories.associate { C -> C.Id to All.count { it.Category == C.Id } }
@@ -48,104 +65,165 @@ private val GarageScreen = FC<Props> {
}
val Shown = Filtered.take(120)
section {
Css("garage")
fun FirstOf(Cat: Int): CatalogItem? = All.firstOrNull { it.Category == Cat }
val Hull = FirstOf(1)
val Turret = FirstOf(0)
val Paint = FirstOf(2)
val Drone = FirstOf(14)
val Grenade = FirstOf(18)
div {
Css("garage-head")
h2 {
+Translations.T("lobby.menu.GARAGE", "车库")
small { +"${Filtered.size} / ${All.size}" }
div {
Css("g2")
header {
Css("g2-head")
button {
Css("g2-back")
onClick = { GarageContext.OnBack() }
i { Css("g2-back-icon") }
}
input {
Css("garage-search")
placeholder = "搜索物品"
value = Query
onChange = { E -> Query = E.target.value }
div { Css("g2-title"); +Translations.T("lobby.menu.GARAGE", "车库") }
div {
Css("g2-currencies")
span { img { src = Asset("static/images/ruby.ce62efbc.svg"); Css("coin") }; +"${GarageContext.Rubies}" }
span { img { src = Asset("static/images/crystalSmall.242e6c15.svg"); Css("coin") }; +"${GarageContext.Crystals}" }
}
}
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" }
Css("g2-body")
aside {
Css("g2-left")
div {
Css("g2-gs")
img { src = Asset("static/images/iconGSSmall.0c0aa8fa.svg") }
b { +"${GarageContext.GearScore}" }
}
div { Css("g2-param"); span { +"最高速度" }; b { +"—" } }
div { Css("g2-param"); span { +"伤害" }; b { +"—" } }
div { Css("g2-param"); span { +"装填" }; b { +"—" } }
div { Css("g2-param"); span { +"护甲" }; b { +"—" } }
div {
Css("g2-resists")
repeat(4) { i -> div { Css("g2-resist"); span { +"+" } } }
}
}
}
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" }
}
Css("g2-scene")
div {
Css("g2-scene-art")
+((Selected ?: Turret ?: Hull)?.Name?.take(1) ?: "T")
}
if (Shown.isEmpty()) {
div { Css("garage-empty"); +"这个分类暂时没有物品。" }
div {
Css("g2-scene-caption")
+((Selected ?: Turret ?: Hull)?.Name ?: "3D 坦克预览")
span { +"3D 加载器接入中" }
}
button {
Css("g2-drawer-toggle")
onClick = { Drawer = !Drawer }
+"物品"
}
}
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", "是")
Css("g2-right")
MountedBlock("无人机", Drone)
MountedBlock("手榴弹", Grenade)
MountedBlock("炮塔", Turret)
MountedBlock("底盘", Hull)
}
}
div {
Css("g2-supplies")
Supply("Repair.13e5e240.svg", "维修")
Supply("armor.b0b6d887.svg", "护甲")
Supply("damage.af4f0289.svg", "伤害")
Supply("Speed.3b207b8e.svg", "速度")
Supply("mine.5a46d3d0.svg", "地雷")
Supply("GoldBox.61e0017c.svg", "金箱")
}
if (Drawer) {
div {
Css("g2-drawer")
div {
Css("g2-drawer-head")
b { +"物品目录" }
input {
Css("garage-search")
placeholder = "搜索物品"
value = Query
onChange = { E -> Query = E.target.value }
}
button { Css("g2-drawer-close"); onClick = { Drawer = false }; +"关闭" }
}
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-grid g2-drawer-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"); +"这个分类暂时没有物品。" }
}
}
}
}
}
private fun react.ChildrenBuilder.MountedBlock(Title: String, Item: CatalogItem?) {
div {
Css("g2-mounted")
div { Css("g2-mounted-title"); +Title }
div {
Css("g2-mounted-art rarity-${Item?.Rarity ?: 0}")
+(Item?.Name?.take(1) ?: "—")
}
div { Css("g2-mounted-name"); +(Item?.Name ?: "未装备") }
}
}
private fun react.ChildrenBuilder.Supply(Icon: String, Title: String) {
div {
Css("g2-supply")
img { src = Asset("static/images/$Icon") }
span { +Title }
b { +"0" }
}
}
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
+410 -317
View File
@@ -1,4 +1,7 @@
/* lobby.css —— 大厅骨架(对齐 Tanki 的深色金属 + 橙色强调) */
/* lobby.css —— 大厅主界面(尺寸/颜色对照 3dtank.com/play/ 线上计算样式)
线上根字号 12.96px(.ksc-37),这里用同样的 max(min(1.48148vh, 1vw), 3px),
因此 6em = 78px、21.875em = 284px、22.5em = 292px,与真实 DOM 一致。 */
@font-face {
font-family: BaseFontRegular;
src: url("/play/static/fonts/Rubik-Regular.7e001ec0.woff") format("woff");
@@ -12,389 +15,351 @@
src: url("/play/static/fonts/Rubik-Bold.26ae1fce.woff") format("woff");
}
:root {
color-scheme: dark;
--bg0: #05080d;
--bg1: #0b1725;
--bg2: #122436;
--panel: rgba(16, 28, 42, 0.88);
--line: #22394f;
--text: #e8eef6;
--muted: #8ea3b8;
--accent: #ffb300;
--accent2: #ff7a18;
--blue: #3f9fe0;
--danger: #e05f5f;
--radius: 10px;
}
* { box-sizing: border-box; }
html, body, #app { height: 100%; }
body {
margin: 0;
overflow: hidden;
}
body { margin: 0; overflow: hidden; background: #001926; color: #fff; }
.lobby {
.ms-root {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
color: var(--text);
font-family: BaseFontRegular, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
line-height: 1.45;
background: var(--bg0);
font-size: max(min(1.48148vh, 1vw), 3px);
font-family: BaseFontRegular, "Segoe UI", sans-serif;
color: #fff;
user-select: none;
overflow: hidden;
}
.lobby-bg {
position: absolute;
inset: 0;
#lobby-canvas {
position: absolute; inset: 0; width: 100%; height: 100%;
display: block; background: #001926;
}
.ms-bg {
position: absolute; inset: 0;
background:
linear-gradient(180deg, rgba(0, 12, 20, 0.55), rgba(0, 10, 18, 0.82)),
linear-gradient(180deg, rgba(0, 25, 38, 0.35), rgba(0, 12, 20, 0.55)),
url("/play/static/images/background.fc2779c1.webp") center / cover no-repeat;
filter: saturate(0.85);
filter: saturate(0.9) brightness(0.8);
}
.lobby > *:not(.lobby-bg) { position: relative; z-index: 1; }
button, input { font: inherit; color: inherit; }
button { cursor: pointer; }
img { display: block; }
/* ---------------- 顶栏 ---------------- */
.topbar {
display: flex; align-items: center; gap: 18px;
height: 72px; padding: 0 22px;
background: linear-gradient(180deg, rgba(5, 14, 22, 0.82), rgba(5, 10, 16, 0.15));
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
/* ---------------- 顶栏 6em ---------------- */
.ms-top {
position: absolute; top: 0; left: 0;
display: flex; align-items: center; justify-content: space-between;
width: 100%; height: 6em; z-index: 2;
border-bottom: 1px solid rgba(255, 255, 255, 0.25);
background: linear-gradient(180deg, rgba(0, 12, 20, 0.55), rgba(0, 12, 20, 0.05));
}
.brand { display: flex; align-items: center; gap: 10px; min-width: 190px; }
.brand .logo { width: 38px; height: 38px; }
.brand span { letter-spacing: 0.14em; font-weight: 700; font-size: 14px; color: #fff; font-family: BaseFontBold, sans-serif; }
.brand b { color: var(--accent); margin-left: 4px; }
.stats { display: flex; align-items: center; justify-content: space-between; gap: 18px; flex: 1; }
.user-block { display: flex; align-items: center; gap: 10px; }
.rank-badge {
width: 44px; height: 44px; border-radius: 50%;
.ms-top-left { display: flex; align-items: center; height: 100%; }
.rank-icon {
width: 5.9375em; height: 6em; display: flex; align-items: center; justify-content: center;
border-right: 1px solid rgba(255, 255, 255, 0.25); cursor: pointer;
}
.rank-icon:hover { background-color: rgba(255, 255, 255, 0.05); }
.rank-icon img { width: 4.375em; height: 4.375em; }
.user-progress {
display: flex; flex-direction: column; justify-content: center;
min-width: 10em; margin-left: 1em; height: 3.75em;
}
.user-name {
font: 500 1.1em/1.2 BaseFontMedium, sans-serif;
text-shadow: 0 0 0.25em rgba(0, 0, 0, 0.5); cursor: pointer;
}
.user-name:hover { color: #ffbc09; }
.xp-bar {
width: 12.5em; height: 0.25em; margin-top: 0.375em;
background: rgba(118, 255, 51, 0.25); border-radius: 0.375em; overflow: hidden;
}
.xp-bar i { display: block; height: 100%; background: rgba(118, 255, 51, 0.8); border-radius: inherit; }
.xp-text {
display: flex; align-items: center; margin-top: 0.35em;
font: 500 0.9375em/1 BaseFontMedium, sans-serif; color: #76ff33;
text-shadow: 0 0 0.25em rgba(0, 0, 0, 0.5);
}
.xp-text span { color: rgba(255, 255, 255, 0.35); margin-left: 0.2em; }
.xp-icon {
width: 0.85em; height: 0.85em; margin-right: 0.25em;
background: url("/play/static/images/experience.402435da.svg") center / contain no-repeat;
}
.ms-top-logo {
position: absolute; left: 0; right: 0; margin: auto;
display: flex; align-items: center; justify-content: center;
font: 700 18px/1 BaseFontBold, sans-serif; color: #241800;
background: radial-gradient(circle at 35% 30%, #ffe08a, #e59a00 70%);
border: 2px solid rgba(255, 255, 255, 0.55);
box-shadow: 0 0 16px rgba(255, 179, 0, 0.35);
height: 3.75em; pointer-events: none; opacity: 0.95;
}
.user-meta b { display: block; font: 600 15px/1.2 BaseFontMedium, sans-serif; color: #fff; }
.user-meta span { font-size: 11px; color: rgba(255, 255, 255, 0.55); letter-spacing: 0.04em; }
.ms-top-logo img { height: 3.75em; }
.ms-top-right { display: flex; align-items: center; justify-content: flex-end; height: 6em; z-index: 5; }
.ms-currencies { display: flex; flex-direction: column; justify-content: center; height: 6em; }
.ms-currency {
position: relative; display: flex; align-items: center; gap: 0.4em;
width: 9.4em; height: 3em; padding: 0 0.75em 0 0.6em; cursor: pointer;
}
.ms-currency .coin { width: 1em; height: 1em; }
.ms-currency span { font: 500 1.05em/1 BaseFontMedium, sans-serif; }
.ms-currency .plus { width: 1em; height: 1em; margin-left: auto; opacity: 0.85; }
.ms-currency:hover { background: rgba(255, 255, 255, 0.06); }
.ms-fullscreen {
display: flex; align-items: center; justify-content: center; gap: 0.3em;
width: 6em; height: 6em; cursor: pointer;
}
.ms-fullscreen i {
width: 1.5em; height: 1.5em; background: #bfd5ff;
mask: url("/play/static/images/fullscreen.0b4547f5.svg") center / contain no-repeat;
-webkit-mask: url("/play/static/images/fullscreen.0b4547f5.svg") center / contain no-repeat;
}
.ms-fullscreen b { font: 600 0.75em/1 BaseFontMedium, sans-serif; color: rgba(255, 255, 255, 0.65); }
.ms-fullscreen:hover i { background: #fff; }
.ms-logout {
display: flex; align-items: center; justify-content: center;
width: 6em; height: 6em; border: 0; background: transparent; cursor: pointer;
}
.ms-logout i {
width: 1.5em; height: 1.5em; background: #bfd5ff;
mask: url("/play/static/images/iconBack.62869951.svg") center / contain no-repeat;
-webkit-mask: url("/play/static/images/iconBack.62869951.svg") center / contain no-repeat;
}
.ms-logout:hover { background: rgba(255, 255, 255, 0.05); }
.ms-logout:hover i { background: #fff; }
.currencies { display: flex; align-items: center; gap: 10px; }
.stat {
display: inline-flex; align-items: center; gap: 7px;
padding: 6px 12px; border-radius: 6px;
background: rgba(2, 10, 16, 0.62);
border: 1px solid rgba(255, 255, 255, 0.10);
color: #eaf3fb; font: 600 14px/1 BaseFontMedium, sans-serif;
/* ---------------- 左侧 50% ---------------- */
.ms-home { position: absolute; inset: 0; z-index: 1; }
.ms-left-gradient {
position: absolute; left: 0; top: 0; bottom: 0;
display: flex; flex-direction: column; align-items: flex-start;
width: 50%; padding-top: 10em;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 100%);
}
.stat img { width: 18px; height: 18px; object-fit: contain; }
.stat.premium { border-color: rgba(255, 179, 0, 0.5); color: var(--accent); }
.stat.muted { color: var(--muted); }
.connect-toggle {
padding: 8px 18px; border-radius: 6px; font-weight: 700;
border: 1px solid #8a5d00;
background: linear-gradient(180deg, #ffc93d, #f29500);
color: #241800;
.ms-play {
display: flex; align-items: center; justify-content: center;
width: 21.875em; height: 10.75em; margin-left: 4.375em;
border: 0; border-radius: 0.5rem; cursor: pointer;
background: url("/play/static/images/videoplay.79570900.gif") center / cover no-repeat;
box-shadow: 0 0 0 0.063em rgba(118, 255, 51, 0.25);
}
.connect-toggle:hover { filter: brightness(1.07); }
.ms-play span {
font: 500 2.75em/1 BaseFontMedium, sans-serif; text-transform: uppercase;
color: #76ff33; text-shadow: 0 0.15em 0.3em rgba(0, 0, 0, 0.5);
}
.ms-play:hover { box-shadow: 0 0 0 2px #76ff33; }
/* ---------------- 主体 ---------------- */
.lobby-body { flex: 1; display: flex; min-height: 0; }
.menu {
width: 108px; padding: 16px 8px; display: flex; flex-direction: column; gap: 4px;
background: linear-gradient(90deg, rgba(3, 10, 16, 0.78), rgba(3, 10, 16, 0.20) 80%, transparent);
.ms-menu {
list-style: none; margin: 1.1em 0 0 4.375em; padding: 0;
display: flex; flex-direction: column; width: 27em;
}
.nav {
display: flex; flex-direction: column; align-items: center; gap: 4px; width: 100%;
padding: 7px 4px; border-radius: 8px; border: 1px solid transparent;
background: transparent; color: #c7d7e6; text-align: center;
.ms-menu-item {
display: flex; align-items: center; height: 4.5em; cursor: pointer;
}
.nav img { width: 46px; height: 46px; object-fit: contain; filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.45)); }
.nav span { font-size: 11px; letter-spacing: 0.02em; }
.nav:hover { background: rgba(255, 255, 255, 0.07); border-color: rgba(255, 255, 255, 0.12); }
.nav.active {
background: linear-gradient(180deg, rgba(255, 179, 0, 0.22), rgba(255, 179, 0, 0.04));
border-color: rgba(255, 179, 0, 0.45); color: #fff;
.ms-menu-item:hover .ms-menu-icon { background-color: #fff; }
.ms-menu-item:hover span { color: #ffbc09; }
.ms-menu-icon {
width: 3.5em; height: 3.5em; margin-left: 0.625em; background-color: #bfd5ff;
mask-position: center; mask-repeat: no-repeat; mask-size: contain;
-webkit-mask-position: center; -webkit-mask-repeat: no-repeat; -webkit-mask-size: contain;
transition: background-color 0.15s;
}
.nav.active img { filter: drop-shadow(0 0 10px rgba(255, 179, 0, 0.55)); }
.content { flex: 1; min-width: 0; padding: 20px 24px; overflow: auto; }
.side {
width: 310px; padding: 14px; display: flex; flex-direction: column; gap: 12px;
border-left: 1px solid var(--line); background: rgba(8, 14, 22, 0.55); overflow: auto;
.ms-menu-icon.icon-containers { mask-image: url("/play/static/images/iconLobbyMenuContainers.f960290f.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuContainers.f960290f.svg"); }
.ms-menu-icon.icon-quests { mask-image: url("/play/static/images/iconLobbyMenuQuests.54801b8b.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuQuests.54801b8b.svg"); }
.ms-menu-icon.icon-garage { mask-image: url("/play/static/images/iconLobbyMenuGarage.0b623165.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuGarage.0b623165.svg"); }
.ms-menu-icon.icon-shop { mask-image: url("/play/static/images/iconLobbyMenuStore.e755fe14.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuStore.e755fe14.svg"); }
.ms-menu-icon.icon-settings { mask-image: url("/play/static/images/iconLobbyMenuSettings.cc00d08a.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuSettings.cc00d08a.svg"); }
.ms-menu-item span {
margin-left: 0.5625em; font: 500 2em/1 BaseFontMedium, sans-serif;
text-transform: uppercase; text-shadow: 0 0.25em 0.25em rgba(0, 0, 0, 0.25);
transition: color 0.15s;
}
/* ---------------- 通用块 ---------------- */
.section-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.section-head h2 { margin: 0; font-size: 22px; letter-spacing: 0.02em; }
.section-head h2 small { margin-left: 10px; color: var(--muted); font-size: 13px; font-weight: 500; }
.search {
width: 240px; padding: 8px 12px; border-radius: 8px;
border: 1px solid var(--line); background: rgba(4, 8, 14, 0.8); color: var(--text); outline: none;
.ms-footer { flex: 1; display: flex; align-items: flex-end; width: 100%; }
.ms-footer ul {
display: flex; align-items: center; list-style: none;
margin: 0 0 0.8em 5em; padding: 0; height: 5.0625em;
}
.search:focus { border-color: var(--blue); }
.hint { color: var(--muted); font-size: 12px; }
.muted { color: var(--muted); }
.small { font-size: 12px; }
.warn { color: var(--accent); font-size: 13px; }
.empty {
padding: 38px 20px; text-align: center; color: var(--muted);
border: 1px dashed var(--line); border-radius: var(--radius); background: rgba(8, 14, 22, 0.4);
.ms-footer-item {
display: flex; align-items: center; justify-content: center;
width: 5em; height: 5em; cursor: pointer;
}
.ms-footer-item:hover { border-radius: 6.25em; box-shadow: 0 0 0 0.125em #fff; }
.ms-footer-icon {
width: 2em; height: 2em; background: #bfd5ff;
mask-position: center; mask-repeat: no-repeat; mask-size: contain;
-webkit-mask-position: center; -webkit-mask-repeat: no-repeat; -webkit-mask-size: contain;
}
.ms-footer-item:hover .ms-footer-icon { background: #fff; }
.ms-footer-icon.icon-news { mask-image: url("/play/static/images/iconLobbyMenuNews.6efe9ae3.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuNews.6efe9ae3.svg"); }
.ms-footer-icon.icon-chat { mask-image: url("/play/static/images/iconLobbyMenuChat.e48a39a4.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuChat.e48a39a4.svg"); }
.ms-footer-icon.icon-friends { mask-image: url("/play/static/images/iconLobbyMenuFriends.28305317.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuFriends.28305317.svg"); }
.ms-footer-icon.icon-clans { mask-image: url("/play/static/images/iconLobbyMenuClan.f01ae646.svg"); -webkit-mask-image: url("/play/static/images/iconLobbyMenuClan.f01ae646.svg"); }
.primary, .ghost {
padding: 9px 16px; border-radius: 8px; font-weight: 700;
/* ---------------- 右侧 ---------------- */
.ms-right-gradient {
position: absolute; right: 0; top: 0; bottom: 0; width: 27.75em;
background: linear-gradient(90deg, #000 0%, rgba(0, 0, 0, 0) 100%);
opacity: 0.75; transform: rotate(180deg); pointer-events: none;
}
.primary { background: linear-gradient(180deg, #ffc93d, #f29500); border: 1px solid #8a5d00; color: #241800; }
.ghost { background: rgba(63, 159, 224, 0.10); border: 1px solid rgba(63, 159, 224, 0.4); color: #cfe6ff; }
.big { padding: 13px 26px; font-size: 15px; }
.primary:hover, .ghost:hover { filter: brightness(1.08); }
/* ---------------- 首页 ---------------- */
.hero {
position: relative; min-height: 240px; display: flex; align-items: center;
padding: 28px 30px; border-radius: 14px; overflow: hidden;
border: 1px solid #2a4258;
.ms-battle-pass {
position: absolute; right: 2.5em; top: 8.5em;
width: 22.5em; height: 10em; padding: 0.5em; box-sizing: border-box;
background: linear-gradient(0deg, rgba(0, 25, 38, 0.25) 0%, #001926 100%);
border-radius: 0.5rem; cursor: pointer; z-index: 1;
filter: drop-shadow(0 0.25rem 0.25rem rgba(0, 0, 0, 0.25));
}
.ms-battle-pass:hover { box-shadow: 0 0 0 0.125em #bfd5ff; }
.ms-bp-head {
display: flex; align-items: center; justify-content: space-between;
height: 2.75em; font-size: 0.9375em;
}
.ms-bp-head span { display: flex; align-items: center; gap: 0.3em; }
.ms-bp-head img { width: 1em; height: 1em; }
.ms-bp-row {
display: flex; align-items: center; height: 3.125em; margin: 0 0.3em 0.3em;
border: 0.063em solid rgba(255, 255, 255, 0.1); border-radius: 0.3em;
background: rgba(0, 0, 0, 0.1);
}
.ms-bp-row i {
width: 4em; height: 2.4em; margin: 0 0.5em;
background: url("/play/static/images/StarMission.d11624d7.svg") center / contain no-repeat;
opacity: 0.9;
}
.ms-bp-row div { display: flex; flex-direction: column; font-size: 0.9375em; }
.ms-bp-row b { font: 500 1em/1.2 BaseFontBold, sans-serif; }
.ms-bp-row span { font-size: 0.8em; }
.ms-bp-row.locked { opacity: 0.55; }
.ms-bp-row.locked i { filter: grayscale(1); }
.ms-announce {
position: absolute; right: 2.5em; bottom: 2.5em;
display: flex; flex-direction: column; width: 22.5em; min-height: 12.5em;
background: rgba(0, 25, 38, 0.35); border: 0.063em solid rgba(255, 255, 255, 0.25);
border-radius: 0.5rem; overflow: hidden; backdrop-filter: blur(0.78em);
}
.ms-announce:hover { border-color: #bfd5ff; }
.ms-announce-art {
height: 9.5em;
background:
radial-gradient(700px 300px at 85% 40%, rgba(255, 179, 0, 0.18), transparent 60%),
linear-gradient(120deg, rgba(16, 38, 60, 0.95), rgba(8, 14, 22, 0.9));
radial-gradient(60% 70% at 50% 40%, rgba(255, 179, 0, 0.18), transparent 70%),
url("/play/static/images/background.fc2779c1.webp") center / cover no-repeat;
}
.hero-copy { max-width: 560px; position: relative; z-index: 1; }
.eyebrow { color: var(--accent); letter-spacing: 0.25em; font-size: 11px; font-weight: 700; }
.hero h1 { margin: 6px 0 8px; font-size: 42px; line-height: 1.05; letter-spacing: 0.02em; }
.hero p { margin: 0 0 16px; color: #b9c9da; }
.hero-actions { display: flex; gap: 10px; }
.hero-art {
position: absolute; right: -30px; bottom: -40px; width: 360px; height: 260px;
background: radial-gradient(closest-side, rgba(63, 159, 224, 0.28), transparent 70%);
filter: blur(2px);
.ms-announce-title {
padding: 0.7em 0.9em; font: 500 1.125em/1.4 BaseFontMedium, sans-serif;
background: linear-gradient(rgba(0, 25, 38, 0), #001926 60%);
}
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-top: 14px; }
.card {
display: flex; align-items: center; gap: 14px; padding: 14px;
border-radius: 12px; border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(19, 34, 50, 0.9), rgba(9, 16, 25, 0.9));
color: var(--text); text-align: left;
/* ---------------- 屏幕 / 返回 / 底部信息 ---------------- */
.ms-content {
position: absolute; inset: 0; z-index: 6;
padding: 7em 3em 2.5em; box-sizing: border-box; overflow: auto;
background: linear-gradient(180deg, rgba(0, 12, 20, 0.86), rgba(0, 8, 14, 0.94));
}
.card:hover { border-color: rgba(255, 179, 0, 0.45); transform: translateY(-1px); }
.card img { width: 42px; height: 42px; object-fit: contain; }
.card b { display: block; font-size: 15px; }
.card span { color: var(--muted); font-size: 12px; }
.card.static { cursor: default; }
.hidden { display: none !important; }
.ms-back {
position: absolute; top: 6.8em; left: 1.6em; z-index: 8;
padding: 0.55em 1.1em; border-radius: 0.4em; cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.25); background: rgba(4, 12, 20, 0.7);
color: #cfe0f0; font: 500 0.95em/1 BaseFontMedium, sans-serif;
}
.ms-back:hover { border-color: #fff; color: #fff; }
.ms-screen { max-width: 1120px; margin: 0 auto; }
.ms-screen-title { margin: 0 0 1em; font: 600 2em/1.2 BaseFontMedium, sans-serif; }
.ms-screen-title small { margin-left: 0.6em; color: rgba(255, 255, 255, 0.5); font-size: 0.55em; font-weight: 400; }
.ms-empty {
padding: 3em 1.5em; text-align: center; color: rgba(255, 255, 255, 0.55);
border: 1px dashed rgba(255, 255, 255, 0.18); border-radius: 0.5em;
}
.ms-chat-box {
height: 24em; overflow: auto; padding: 0.7em; border-radius: 0.5em;
background: rgba(0, 8, 14, 0.7); font: 12px/1.55 Consolas, monospace;
}
.ms-log-line { padding: 0.1em 0; border-bottom: 1px solid rgba(255, 255, 255, 0.06); color: #b8c9da; }
.ms-news-list { display: flex; flex-direction: column; gap: 0.8em; }
.ms-news {
padding: 1em; border-radius: 0.5em; border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(4, 14, 22, 0.6);
}
.ms-news b { display: block; margin-bottom: 0.4em; }
.ms-news p { margin: 0; color: rgba(255, 255, 255, 0.6); font-size: 0.85em; white-space: pre-wrap; }
.ms-client-info {
position: absolute; right: 0; bottom: 0; height: 1.3rem; z-index: 101;
display: flex; align-items: center; justify-content: flex-end;
padding-right: 1.8rem; pointer-events: none;
font-size: 0.9375em; letter-spacing: 0.0375em; color: rgba(255, 255, 255, 0.7);
}
.ms-client-info i { margin: 0 0.4rem; font-style: normal; color: rgba(255, 255, 255, 0.6); }
/* ---------------- 车库 / 商店 ---------------- */
.tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.tab {
padding: 7px 12px; border-radius: 999px; font-size: 13px;
border: 1px solid var(--line); background: rgba(8, 14, 22, 0.6); color: #b9c9da;
}
.tab i { margin-left: 6px; color: var(--muted); font-style: normal; font-size: 11px; }
.tab.active { border-color: var(--accent); color: #241800; background: linear-gradient(180deg, #ffc93d, #f29500); }
.tab.active i { color: #4a3200; }
.item-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px;
}
.item {
position: relative; display: flex; flex-direction: column; overflow: hidden;
border-radius: 10px; border: 1px solid var(--line);
background: linear-gradient(180deg, rgba(18, 31, 45, 0.95), rgba(8, 13, 20, 0.95));
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--rarity) 35%, transparent);
}
.item:hover { transform: translateY(-1px); border-color: var(--rarity); }
.item-art {
height: 70px; display: flex; align-items: center; justify-content: center;
background:
radial-gradient(closest-side, color-mix(in srgb, var(--rarity) 45%, transparent), transparent 70%),
linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent);
}
.item-art span { font-size: 28px; font-weight: 800; color: var(--rarity); opacity: 0.9; }
.item-body { padding: 8px 10px 10px; display: flex; flex-direction: column; gap: 2px; }
.item-body b {
font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.badge {
position: absolute; top: 6px; right: 6px; padding: 1px 6px; border-radius: 999px;
font-size: 10px; font-weight: 800; background: var(--accent); color: #241800;
}
/* ---------------- 战斗 ---------------- */
.mode-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 10px; }
.mode-card {
text-align: left; padding: 16px; border-radius: 12px;
border: 1px solid var(--line); background: linear-gradient(180deg, rgba(18, 31, 45, 0.92), rgba(8, 13, 20, 0.92));
}
.mode-card:hover { border-color: rgba(63, 159, 224, 0.6); }
.mode-card b { display: block; font-size: 16px; margin-bottom: 6px; color: #fff; }
.mode-card span { color: var(--muted); font-size: 12px; }
/* ---------------- 侧栏 ---------------- */
.panel {
padding: 12px; border-radius: 12px; border: 1px solid var(--line);
background: var(--panel);
}
.panel h3 { margin: 0 0 10px; font-size: 14px; color: #dce8f4; letter-spacing: 0.04em; }
.chat-box {
height: 240px; overflow: auto; padding: 8px; border-radius: 8px;
background: rgba(3, 7, 12, 0.75); font: 12px/1.55 Consolas, "Cascadia Mono", monospace;
}
.log-line { color: #b8c9da; padding: 1px 0; border-bottom: 1px solid rgba(34, 57, 79, 0.35); }
.chat-input { display: flex; gap: 6px; margin-top: 8px; }
.chat-input input { flex: 1; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--line); background: #0a1119; }
.chat-input button { padding: 6px 10px; border-radius: 6px; border: 1px solid var(--line); background: #142434; color: var(--muted); }
.news-list { display: flex; flex-direction: column; gap: 8px; }
.news { padding: 12px; border-radius: 10px; border: 1px solid var(--line); background: rgba(8, 14, 22, 0.55); }
.news b { display: block; margin-bottom: 4px; }
.news p { margin: 0; color: var(--muted); font-size: 12px; white-space: pre-wrap; }
/* ---------------- 底部 / 连接面板 ---------------- */
.statusbar {
height: 28px; display: flex; align-items: center; padding: 0 14px;
border-top: 1px solid var(--line); background: rgba(5, 9, 14, 0.9);
color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.connect-panel {
position: fixed; top: 76px; right: 18px; width: 360px; z-index: 30;
padding: 16px; border-radius: 12px; border: 1px solid var(--line);
background: linear-gradient(180deg, #16283a, #0a121c);
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
}
.connect-panel.hidden { display: none; }
.connect-panel h3 { margin: 0 0 12px; }
.connect-panel label { display: block; margin-bottom: 10px; color: var(--muted); font-size: 12px; }
.connect-panel input {
display: block; width: 100%; margin-top: 4px; padding: 8px 10px; border-radius: 7px;
border: 1px solid var(--line); background: #070d14; color: var(--text);
}
.connect-actions { display: flex; gap: 8px; }
.connect-actions button {
padding: 8px 14px; border-radius: 7px; border: 1px solid var(--line);
background: #142434; color: #cfe0f0;
}
.connect-actions .primary { background: linear-gradient(180deg, #ffc93d, #f29500); border-color: #8a5d00; color: #241800; }
@media (max-width: 1100px) {
.side { display: none; }
.menu { width: 64px; }
.nav span { display: none; }
.nav { justify-content: center; }
}
/* ---------------- React 车库 ---------------- */
.garage-host { height: 100%; min-height: 0; }
/* ---------------- React 车库(尺寸随根字号) ---------------- */
.ms-garage-screen { padding-top: 0; }
#garage-root { height: calc(100vh - 9.5em); min-height: 28em; }
.garage {
display: flex; flex-direction: column; gap: 12px; height: 100%; min-height: 0;
display: flex; flex-direction: column; gap: 0.9em; 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-head { display: flex; align-items: center; justify-content: space-between; gap: 0.9em; }
.garage-head h2 { margin: 0; font: 600 1.7em/1.2 BaseFontMedium, sans-serif; color: #fff; }
.garage-head h2 small { margin-left: 0.5em; color: rgba(255, 255, 255, 0.45); font-size: 0.5em; 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;
width: 18em; padding: 0.55em 0.9em; border-radius: 0.4em;
border: 1px solid rgba(255, 255, 255, 0.16); background: rgba(3, 10, 16, 0.72);
color: #fff; outline: none; font-size: 0.9em;
}
.garage-search:focus { border-color: rgba(255,179,0,.6); }
.garage-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.garage-search:focus { border-color: rgba(255, 179, 0, 0.6); }
.garage-tabs { display: flex; flex-wrap: wrap; gap: 0.45em; }
.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;
display: inline-flex; align-items: center; gap: 0.5em;
padding: 0.45em 0.9em; border-radius: 0.35em; font-size: 0.85em;
border: 1px solid rgba(255, 255, 255, 0.12); background: rgba(4, 12, 20, 0.62); color: #c7d7e6;
}
.garage-tab span { color: var(--muted); font-size: 10px; }
.garage-tab:hover { border-color: rgba(255,255,255,.3); }
.garage-tab span { color: rgba(255, 255, 255, 0.45); font-size: 0.8em; }
.garage-tab:hover { border-color: rgba(255, 255, 255, 0.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-main { flex: 1; min-height: 0; display: flex; gap: 1em; }
.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;
display: grid; grid-template-columns: repeat(auto-fill, minmax(9.5em, 1fr)); gap: 0.55em; padding-right: 0.3em;
}
.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);
padding: 0; border-radius: 0.4em; overflow: hidden; text-align: left;
border: 1px solid rgba(255, 255, 255, 0.1);
border-top: 2px solid var(--rarity, #9aa3b2);
background: linear-gradient(180deg, rgba(20,34,48,.92), rgba(6,12,18,.94));
color: var(--text);
background: linear-gradient(180deg, rgba(20, 34, 48, 0.92), rgba(6, 12, 18, 0.94));
color: #fff; cursor: pointer;
}
.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.selected { box-shadow: 0 0 0 1px var(--rarity, #ffb300), 0 0 1.2em rgba(255, 179, 0, 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);
height: 4.6em; display: flex; align-items: center; justify-content: center;
font: 800 1.9em/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-name { padding: 0 0.6em; font-size: 0.85em; 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;
padding: 0.15em 0.6em 0.55em; display: flex; align-items: center; justify-content: space-between; gap: 0.4em;
color: rgba(255, 255, 255, 0.5); font-size: 0.72em;
}
.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;
position: absolute; top: 0.35em; right: 0.35em; padding: 0 0.35em; border-radius: 0.2em;
background: #ffb300; color: #241800; font: 800 0.65em/1.1em BaseFontBold, sans-serif;
}
.garage-empty { grid-column: 1 / -1; padding: 40px; text-align: center; color: var(--muted); }
.garage-empty { grid-column: 1 / -1; padding: 3em; text-align: center; color: rgba(255, 255, 255, 0.55); }
.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);
width: 22em; flex: 0 0 22em; display: flex; flex-direction: column; gap: 0.55em;
padding: 0.8em; border-radius: 0.5em; border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(6, 14, 22, 0.78);
}
.garage-detail h3 { margin: 0 0 4px; font: 600 17px/1.3 BaseFontMedium, sans-serif; }
.garage-detail h3 { margin: 0 0 0.2em; font: 600 1.25em/1.3 BaseFontMedium, sans-serif; }
.garage-preview {
height: 190px; border-radius: 6px; border: 1px solid rgba(255,255,255,.08);
height: 14em; border-radius: 0.4em; border: 1px solid rgba(255, 255, 255, 0.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);
radial-gradient(60% 60% at 50% 45%, rgba(63, 159, 224, 0.16), transparent 70%),
linear-gradient(180deg, rgba(255, 255, 255, 0.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-preview-label { color: rgba(255, 255, 255, 0.45); font-size: 0.72em; text-align: center; margin-top: -0.2em; }
.garage-props { display: flex; flex-direction: column; gap: 0.3em; margin-top: 0.5em; }
.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;
padding: 0.35em 0.55em; border-radius: 0.25em; background: rgba(255, 255, 255, 0.03); font-size: 0.82em;
}
.garage-prop span { color: var(--muted); }
.garage-prop span { color: rgba(255, 255, 255, 0.5); }
.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; }
@@ -403,6 +368,134 @@ img { display: block; }
.rarity-5 { --rarity: #e05f5f; }
@media (max-width: 900px) {
.lobby-chat { display: none; }
.play-fab { right: 18px; bottom: 24px; }
.ms-top-logo { display: none; }
.ms-battle-pass, .ms-announce { display: none; }
.garage-detail { display: none; }
.ms-content { padding: 7em 1em 2em; }
}
/* ---------------- 车库(原版布局) ---------------- */
.ms-content { padding: 0; }
.ms-screen { padding: 7em 3em 2.5em; box-sizing: border-box; }
.ms-garage-screen { padding: 0; height: 100%; }
#garage-root { height: 100%; min-height: 0; }
.g2 {
display: flex; flex-direction: column; height: 100%; min-height: 0;
background: linear-gradient(180deg, rgba(0, 12, 20, 0.35), rgba(0, 8, 14, 0.75));
}
.g2-head {
display: flex; align-items: center; height: 6em; flex: 0 0 6em;
border-bottom: 1px solid rgba(255, 255, 255, 0.25);
background: linear-gradient(180deg, rgba(0, 12, 20, 0.6), rgba(0, 12, 20, 0.12));
}
.g2-back {
width: 6em; height: 6em; display: flex; align-items: center; justify-content: center;
border: 0; background: transparent; cursor: pointer;
}
.g2-back:hover { background: rgba(255, 255, 255, 0.05); }
.g2-back-icon {
width: 1.5em; height: 1.2em; background: #bfd5ff;
mask: url("/play/static/images/iconBackArrow.9f950ca9.svg") center / contain no-repeat;
-webkit-mask: url("/play/static/images/iconBackArrow.9f950ca9.svg") center / contain no-repeat;
}
.g2-title {
position: absolute; left: 0; right: 0; margin: auto; width: 12em; text-align: center;
font: 500 1.4em/1 BaseFontMedium, sans-serif; text-transform: uppercase;
pointer-events: none;
}
.g2-currencies { margin-left: auto; display: flex; align-items: center; gap: 1.2em; padding-right: 1.5em; font: 500 1em/1 BaseFontMedium, sans-serif; }
.g2-currencies span { display: inline-flex; align-items: center; gap: 0.35em; }
.g2-currencies .coin { width: 1.1em; height: 1.1em; }
.g2-body { flex: 1; min-height: 0; display: flex; gap: 1em; padding: 4em 6em; box-sizing: border-box; }
.g2-left { width: 22em; flex: 0 0 22em; display: flex; flex-direction: column; gap: 0.35em; }
.g2-gs {
display: flex; align-items: center; gap: 0.5em; margin-bottom: 0.5em;
padding: 0.6em 0.8em; border-radius: 0.4em; background: rgba(0, 20, 32, 0.55);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.g2-gs img { width: 2.2em; height: 1.1em; }
.g2-gs b { font: 700 1.8em/1 BaseFontBold, sans-serif; color: #fff; }
.g2-param {
display: flex; align-items: center; justify-content: space-between;
padding: 0.45em 0.7em; border-radius: 0.3em; background: rgba(3, 12, 20, 0.5);
border: 1px solid rgba(255, 255, 255, 0.06); font-size: 0.85em;
}
.g2-param span { color: rgba(255, 255, 255, 0.55); }
.g2-resists { display: flex; gap: 0.5em; margin-top: auto; }
.g2-resist {
width: 4em; height: 5em; display: flex; align-items: center; justify-content: center;
border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 0.4em;
background: rgba(0, 18, 30, 0.5); color: rgba(255, 255, 255, 0.35);
}
.g2-scene {
flex: 1; min-width: 0; position: relative; display: flex; flex-direction: column;
align-items: center; justify-content: center; border-radius: 0.5em;
border: 1px solid rgba(255, 255, 255, 0.06);
background:
radial-gradient(60% 55% at 50% 45%, rgba(63, 159, 224, 0.14), transparent 70%),
linear-gradient(180deg, rgba(255, 255, 255, 0.03), transparent);
}
.g2-scene-art {
font: 800 16em/1 BaseFontBold, sans-serif; color: rgba(63, 159, 224, 0.18);
text-shadow: 0 0 0.2em rgba(0, 0, 0, 0.4); user-select: none;
}
.g2-scene-caption {
position: absolute; bottom: 1.2em; left: 0; right: 0; text-align: center;
font: 500 1em/1.4 BaseFontMedium, sans-serif; color: rgba(255, 255, 255, 0.75);
}
.g2-scene-caption span { display: block; font-size: 0.65em; color: rgba(255, 255, 255, 0.35); }
.g2-drawer-toggle {
position: absolute; left: 1em; bottom: 1em; padding: 0.6em 1.4em;
border-radius: 0.4em; border: 1px solid #8a5d00; cursor: pointer;
background: linear-gradient(180deg, #ffc93d, #f29500); color: #241800;
font: 700 0.9em/1 BaseFontBold, sans-serif;
}
.g2-right { width: 23em; flex: 0 0 23em; display: grid; grid-template-columns: 1fr 1fr; gap: 0.6em; align-content: start; }
.g2-mounted {
display: flex; flex-direction: column; padding: 0.5em; border-radius: 0.4em;
border: 1px solid rgba(255, 255, 255, 0.1); background: rgba(3, 12, 20, 0.55);
}
.g2-mounted-title { font-size: 0.72em; color: rgba(255, 255, 255, 0.5); text-transform: uppercase; }
.g2-mounted-art {
height: 4.6em; display: flex; align-items: center; justify-content: center;
font: 800 1.8em/1 BaseFontBold, sans-serif; color: var(--rarity, #9aa3b2);
background: radial-gradient(closest-side, color-mix(in srgb, var(--rarity, #9aa3b2) 30%, transparent), transparent 72%);
}
.g2-mounted-name { font-size: 0.8em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.g2-supplies {
flex: 0 0 9.6em; display: flex; align-items: center; justify-content: center; gap: 1.2em;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(0, 10, 18, 0.55);
}
.g2-supply {
width: 7.5em; height: 4em; display: flex; align-items: center; gap: 0.5em;
justify-content: center; border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.35em; background: rgba(3, 12, 20, 0.6); font-size: 0.72em;
}
.g2-supply img { width: 1.8em; height: 1.8em; object-fit: contain; }
.g2-supply span { color: rgba(255, 255, 255, 0.6); }
.g2-supply b { color: #fff; }
.g2-drawer {
position: absolute; inset: 6em 0 0 0; z-index: 12; display: flex; flex-direction: column;
padding: 1em 2em 1.5em; background: rgba(2, 8, 14, 0.94); backdrop-filter: blur(6px);
}
.g2-drawer-head { display: flex; align-items: center; gap: 1em; margin-bottom: 0.8em; }
.g2-drawer-head b { font: 600 1.3em/1 BaseFontMedium, sans-serif; }
.g2-drawer-head .garage-search { margin-left: auto; }
.g2-drawer-close {
padding: 0.5em 1.2em; border-radius: 0.35em; border: 1px solid rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.05); color: #fff; cursor: pointer;
}
.g2-drawer-grid { flex: 1; min-height: 0; overflow: auto; margin-top: 0.8em; }
@media (max-width: 900px) {
.g2-left, .g2-right { display: none; }
.g2-supplies { gap: 0.4em; }
.g2-supply { width: auto; padding: 0 0.5em; }
.g2-supply span { display: none; }
}