feat(ui): 本地资源代理 + 车库全屏布局与退出入口

This commit is contained in:
TermiNexus committed 2026-10-25 20:50:13 +08:00
1 parent 87961f6209
commit df28be81f6
8 files changed
+944 -15

No files matched your search

@@ -78,6 +78,7 @@ private val GarageScreen = FC<Props> {
header {
Css("g2-head")
style = js("({ position: 'relative', zIndex: 5 })")
button {
Css("g2-back")
onClick = { GarageContext.OnBack() }
@@ -89,6 +90,11 @@ private val GarageScreen = FC<Props> {
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}" }
}
button {
Css("g2-exit")
onClick = { js("window.location.reload()") }
i { Css("g2-exit-icon") }
}
}
div {
@@ -96,6 +102,7 @@ private val GarageScreen = FC<Props> {
aside {
Css("g2-left")
style = js("({ zIndex: 3 })")
div {
Css("g2-gs")
img { src = Asset("static/images/iconGSSmall.0c0aa8fa.svg") }
@@ -131,6 +138,7 @@ private val GarageScreen = FC<Props> {
aside {
Css("g2-right")
style = js("({ zIndex: 3 })")
MountedBlock("无人机", Drone)
MountedBlock("手榴弹", Grenade)
MountedBlock("炮塔", Turret)
@@ -140,6 +148,7 @@ private val GarageScreen = FC<Props> {
div {
Css("g2-supplies")
style = js("({ zIndex: 3 })")
Supply("Repair.13e5e240.svg", "维修")
Supply("armor.b0b6d887.svg", "护甲")
Supply("damage.af4f0289.svg", "伤害")
@@ -4,7 +4,7 @@ import * as THREE from 'three';
import { TDSLoader } from '/vendor/three/TDSLoader.js';
import { OrbitControls } from '/vendor/three/OrbitControls.js';
const MapUrl = 'https://res.3dtank.com/613/2501/252/46/32047724143075/object.3ds';
const MapUrl = 'http://127.0.0.1:8082/res/613/2501/252/46/32047724143075/object.3ds';
let Current = null;
function PlaceholderTank() {
@@ -42,21 +42,22 @@ function Mount(CanvasId) {
}
Mount.Retry = 0;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true });
renderer.setClearColor(0x080f16, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(38, 1, 0.1, 100);
camera.position.set(4.2, 2.6, 5.2);
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 200);
camera.position.set(7, 4.5, 9);
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 1.0, 0);
controls.target.set(0, 2.0, 0);
controls.enablePan = false;
controls.enableZoom = true;
controls.minDistance = 3;
controls.maxDistance = 12;
controls.minDistance = 5;
controls.maxDistance = 26;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.7;
@@ -87,11 +88,10 @@ function Mount(CanvasId) {
Platform.castShadow = true;
scene.add(Platform);
const Tank = PlaceholderTank();
Tank.position.y = 0.18;
scene.add(Tank);
// 真实坦克模型待 Item3DCC.Resource 索引接入;先不放几何占位,避免误导。
new TDSLoader().load(
const ShowMap = false;
if (ShowMap) new TDSLoader().load(
MapUrl,
(Obj) => {
Obj.traverse((N) => {
@@ -112,7 +112,7 @@ function Mount(CanvasId) {
const Box = new THREE.Box3().setFromObject(Obj);
const Size = Box.getSize(new THREE.Vector3());
const Center = Box.getCenter(new THREE.Vector3());
const Scale = 6.5 / Math.max(Size.x, Size.y, Size.z);
const Scale = 11 / Math.max(Size.x, Size.y, Size.z);
Obj.scale.setScalar(Scale);
Obj.position.set(-Center.x * Scale, -Box.min.y * Scale, -Center.z * Scale);
scene.add(Obj);
@@ -0,0 +1,168 @@
// Garage3D.js —— 车库 3D(Three.js 加载官方 3DS 场景 + 占位坦克)
// 由 index.html 以 <script type="module"> 加载;import map 把 'three' 指到本地镜像。
import * as THREE from 'three';
import { TDSLoader } from '/vendor/three/TDSLoader.js';
import { OrbitControls } from '/vendor/three/OrbitControls.js';
const MapUrl = 'http://127.0.0.1:8082/res/613/2501/252/46/32047724143075/object.3ds';
let Current = null;
function PlaceholderTank() {
const G = new THREE.Group();
const HullMat = new THREE.MeshStandardMaterial({ color: 0x4c5a3a, roughness: 0.65, metalness: 0.35 });
const DarkMat = new THREE.MeshStandardMaterial({ color: 0x1a1d20, roughness: 0.9 });
const Hull = new THREE.Mesh(new THREE.BoxGeometry(2.1, 0.55, 1.25), HullMat);
Hull.position.y = 0.55;
Hull.castShadow = true;
G.add(Hull);
const Turret = new THREE.Mesh(new THREE.CylinderGeometry(0.48, 0.55, 0.34, 24), HullMat);
Turret.position.set(0, 0.98, 0.05);
Turret.castShadow = true;
G.add(Turret);
const Barrel = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.07, 1.55, 12), DarkMat);
Barrel.rotation.x = Math.PI / 2;
Barrel.position.set(0, 0.98, 0.9);
G.add(Barrel);
for (const X of [-1.02, 1.02]) {
const Track = new THREE.Mesh(new THREE.BoxGeometry(0.36, 0.52, 1.55), DarkMat);
Track.position.set(X, 0.36, 0);
Track.castShadow = true;
G.add(Track);
}
return G;
}
function Mount(CanvasId) {
if (Current && Current.scene) return;
const canvas = document.getElementById(CanvasId);
if (!canvas) {
if (Mount.Retry === undefined) Mount.Retry = 0;
if (Mount.Retry++ < 100) setTimeout(() => Mount(CanvasId), 100);
return;
}
Mount.Retry = 0;
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: false, preserveDrawingBuffer: true });
renderer.setClearColor(0x080f16, 1);
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(40, 1, 0.1, 200);
camera.position.set(7, 4.5, 9);
const controls = new OrbitControls(camera, canvas);
controls.target.set(0, 2.0, 0);
controls.enablePan = false;
controls.enableZoom = true;
controls.minDistance = 5;
controls.maxDistance = 26;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.7;
scene.add(new THREE.HemisphereLight(0xbfd5ff, 0x0a141e, 2.2));
const Sun = new THREE.DirectionalLight(0xffffff, 2.4);
Sun.position.set(4, 7, 5);
Sun.castShadow = true;
Sun.shadow.mapSize.set(1024, 1024);
scene.add(Sun);
const Rim = new THREE.DirectionalLight(0x3f9fe0, 1.0);
Rim.position.set(-5, 3, -4);
scene.add(Rim);
const Floor = new THREE.Mesh(
new THREE.CircleGeometry(4.2, 64),
new THREE.MeshStandardMaterial({ color: 0x0d1a24, roughness: 0.9, metalness: 0.1 }),
);
Floor.rotation.x = -Math.PI / 2;
Floor.receiveShadow = true;
scene.add(Floor);
const Platform = new THREE.Mesh(
new THREE.CylinderGeometry(2.1, 2.25, 0.18, 64),
new THREE.MeshStandardMaterial({ color: 0x1b2c3a, roughness: 0.6, metalness: 0.5 }),
);
Platform.position.y = 0.09;
Platform.receiveShadow = true;
Platform.castShadow = true;
scene.add(Platform);
// 真实坦克模型待 Item3DCC.Resource 索引接入;先不放几何占位,避免误导。
const ShowMap = false;
if (ShowMap) new TDSLoader().load(
MapUrl,
(Obj) => {
Obj.traverse((N) => {
if (N.isMesh) {
N.castShadow = true;
N.receiveShadow = true;
if (N.material) {
N.material = new THREE.MeshStandardMaterial({
color: N.material.color ? N.material.color.getHex() : 0x6f7b86,
roughness: 0.8,
metalness: 0.15,
opacity: N.material.opacity === undefined ? 1 : N.material.opacity,
transparent: !!N.material.transparent,
});
}
}
});
const Box = new THREE.Box3().setFromObject(Obj);
const Size = Box.getSize(new THREE.Vector3());
const Center = Box.getCenter(new THREE.Vector3());
const Scale = 11 / Math.max(Size.x, Size.y, Size.z);
Obj.scale.setScalar(Scale);
Obj.position.set(-Center.x * Scale, -Box.min.y * Scale, -Center.z * Scale);
scene.add(Obj);
},
undefined,
() => {},
);
function Resize() {
const Rect = (canvas.parentElement || canvas).getBoundingClientRect();
const W = Math.max(1, Math.floor(Rect.width));
const H = Math.max(1, Math.floor(Rect.height));
if (canvas.width !== W || canvas.height !== H) {
renderer.setSize(W, H, false);
camera.aspect = W / H;
camera.updateProjectionMatrix();
}
}
const Observer = new ResizeObserver(Resize);
Observer.observe(canvas.parentElement || canvas);
Resize();
let Alive = true;
function Frame() {
if (!Alive) return;
Resize();
controls.update();
renderer.render(scene, camera);
requestAnimationFrame(Frame);
}
Frame();
Current = {
scene,
camera,
renderer,
dispose() {
Alive = false;
Observer.disconnect();
controls.dispose();
renderer.dispose();
Current = null;
window.__garage3d = null;
},
};
window.__garage3d = Current;
}
function Dispose() {
if (Current) Current.dispose();
}
window.Garage3D = { mount: Mount, dispose: Dispose };
@@ -4,12 +4,12 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>TankiOffline · 大厅</title>
<link rel="stylesheet" href="entrance.css" />
<link rel="stylesheet" href="lobby.css" />
<link rel="stylesheet" href="entrance.css?v=6" />
<link rel="stylesheet" href="lobby2.css" />
<script type="importmap">
{ "imports": { "three": "/vendor/three/three.module.js" } }
</script>
<script type="module" src="/Garage3D.js"></script>
<script type="module" src="/Garage3D2.js"></script>
</head>
<body>
<div id="app"></div>
@@ -524,3 +524,36 @@ body { margin: 0; overflow: hidden; background: #001926; color: #fff; }
.group-icon.g1 { transform: translateX(-0.5em); }
.group-icon.g2 { transform: translateX(0.5em); }
.premium-block + .group-block + .rank-icon { border-left: 1px solid rgba(255, 255, 255, 0.25); }
/* 车库全屏:3D 覆盖整页,左右参数/配件与底部道具作为浮层,header 保留 */
.g2 { position: relative; }
.g2-body {
position: absolute; inset: 6em 0 0 0; display: block; padding: 0; gap: 0;
}
.g2-scene {
position: absolute; inset: 0; border: 0; border-radius: 0; background: #080f16;
}
.g2-left {
position: absolute; left: 2em; top: 2em; width: 22em; max-height: calc(100% - 4em);
overflow: auto; padding: 1em; box-sizing: border-box;
background: rgba(4, 14, 22, 0.82); border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 0.5em; backdrop-filter: blur(6px);
}
.g2-right {
position: absolute; right: 2em; top: 2em; width: 23em; padding: 1em;
box-sizing: border-box; background: rgba(4, 14, 22, 0.82);
border: 1px solid rgba(255, 255, 255, 0.10); border-radius: 0.5em;
backdrop-filter: blur(6px);
}
.g2-supplies {
position: absolute; left: 0; right: 0; bottom: 0; height: 9.6em;
background: rgba(3, 10, 16, 0.82); backdrop-filter: blur(6px);
}
.g2-scene-caption { bottom: 10.5em; pointer-events: none; }
.g2-resists { margin-top: 1em; }
.g2-head { position: relative; z-index: 5; }
.g2-scene { z-index: 0; }
.g2-left, .g2-right { z-index: 3; }
.g2-supplies { z-index: 3; }
.g2-drawer-toggle { z-index: 4; }
@@ -0,0 +1,564 @@
/* 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");
}
@font-face {
font-family: BaseFontMedium;
src: url("/play/static/fonts/Rubik-Medium.fc871fe9.woff") format("woff");
}
@font-face {
font-family: BaseFontBold;
src: url("/play/static/fonts/Rubik-Bold.26ae1fce.woff") format("woff");
}
html, body, #app { height: 100%; }
body { margin: 0; overflow: hidden; background: #001926; color: #fff; }
.ms-root {
position: relative;
width: 100%;
height: 100%;
font-size: max(min(1.48148vh, 1vw), 3px);
font-family: BaseFontRegular, "Segoe UI", sans-serif;
color: #fff;
user-select: none;
overflow: hidden;
}
#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, 25, 38, 0.35), rgba(0, 12, 20, 0.55)),
url("/play/static/images/background.fc2779c1.webp") center / cover no-repeat;
filter: saturate(0.9) brightness(0.8);
}
/* ---------------- 顶栏 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));
}
.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;
height: 3.75em; pointer-events: none; opacity: 0.95;
}
.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; }
/* ---------------- 左侧 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%);
}
.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);
}
.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; }
.ms-menu {
list-style: none; margin: 1.1em 0 0 4.375em; padding: 0;
display: flex; flex-direction: column; width: 27em;
}
.ms-menu-item {
display: flex; align-items: center; height: 4.5em; cursor: pointer;
}
.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;
}
.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;
}
.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;
}
.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"); }
/* ---------------- 右侧 ---------------- */
.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;
}
.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(60% 70% at 50% 40%, rgba(255, 179, 0, 0.18), transparent 70%),
url("/play/static/images/background.fc2779c1.webp") center / cover no-repeat;
}
.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%);
}
/* ---------------- 屏幕 / 返回 / 底部信息 ---------------- */
.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));
}
.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); }
/* ---------------- React 车库(尺寸随根字号) ---------------- */
.ms-garage-screen { padding-top: 0; }
#garage-root { height: calc(100vh - 9.5em); min-height: 28em; }
.garage {
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: 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: 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, 0.6); }
.garage-tabs { display: flex; flex-wrap: wrap; gap: 0.45em; }
.garage-tab {
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: 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: 1em; }
.garage-grid {
flex: 1; min-width: 0; overflow: auto; align-content: start;
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: 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, 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 1.2em rgba(255, 179, 0, 0.18); }
.garage-card-art {
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 0.6em; font-size: 0.85em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.garage-card-meta {
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: 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: 3em; text-align: center; color: rgba(255, 255, 255, 0.55); }
.garage-detail {
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 0.2em; font: 600 1.25em/1.3 BaseFontMedium, sans-serif; }
.garage-preview {
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, 0.16), transparent 70%),
linear-gradient(180deg, rgba(255, 255, 255, 0.04), transparent);
}
.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: 0.35em 0.55em; border-radius: 0.25em; background: rgba(255, 255, 255, 0.03); font-size: 0.82em;
}
.garage-prop span { color: rgba(255, 255, 255, 0.5); }
.garage-prop b { font-weight: 600; }
.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) {
.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; }
}
/* 顶栏:VIP / 组队图标(对照线上 .ksc-38 / .ksc-40) */
.premium-block {
position: relative; width: 6em; height: 6em;
display: flex; align-items: center; justify-content: center;
background: radial-gradient(100% 100% at 0% 0%, rgba(255, 204, 0, 0.75) 0%, rgba(255, 38, 74, 0.5) 50%, rgba(255, 38, 74, 0) 100%);
}
.premium-block.off { opacity: 0.35; filter: grayscale(0.6); }
.premium-icon {
width: 2em; height: 2em;
background: url("/play/static/images/iconButtonPremium.bc17a592.svg") center / contain no-repeat;
}
.group-block {
position: relative; width: 6em; height: 6em;
display: flex; align-items: center; justify-content: center;
}
.group-icon {
position: absolute; width: 1.5em; height: 1.5em;
background: rgba(191, 212, 255, 0.5);
mask: url("/play/static/images/groupUser.67c9ec61.svg") center / contain no-repeat;
-webkit-mask: url("/play/static/images/groupUser.67c9ec61.svg") center / contain no-repeat;
}
.group-icon.g1 { transform: translateX(-0.5em); }
.group-icon.g2 { transform: translateX(0.5em); }
.premium-block + .group-block + .rank-icon { border-left: 1px solid rgba(255, 255, 255, 0.25); }
/* 车库全屏:3D 覆盖整页,左右参数/配件与底部道具作为浮层,header 保留 */
.g2 { position: relative; }
.g2-body {
position: absolute; inset: 6em 0 0 0; display: block; padding: 0; gap: 0;
}
.g2-scene {
position: absolute; inset: 0; border: 0; border-radius: 0; background: #080f16;
}
.g2-left {
position: absolute; left: 2em; top: 2em; width: 22em; max-height: calc(100% - 4em);
overflow: auto; padding: 1em; box-sizing: border-box;
background: rgba(4, 14, 22, 0.82); border: 1px solid rgba(255, 255, 255, 0.10);
border-radius: 0.5em; backdrop-filter: blur(6px);
}
.g2-right {
position: absolute; right: 2em; top: 2em; width: 23em; padding: 1em;
box-sizing: border-box; background: rgba(4, 14, 22, 0.82);
border: 1px solid rgba(255, 255, 255, 0.10); border-radius: 0.5em;
backdrop-filter: blur(6px);
}
.g2-supplies {
position: absolute; left: 0; right: 0; bottom: 0; height: 9.6em;
background: rgba(3, 10, 16, 0.82); backdrop-filter: blur(6px);
}
.g2-scene-caption { bottom: 10.5em; pointer-events: none; }
.g2-resists { margin-top: 1em; }
.g2-head { position: relative; z-index: 5; }
.g2-scene { z-index: 0; }
.g2-left, .g2-right { z-index: 3; }
.g2-supplies { z-index: 3; }
.g2-drawer-toggle { z-index: 4; }
.g2-exit { width: 6em; height: 6em; display: flex; align-items: center; justify-content: center; border: 0; background: transparent; cursor: pointer; }
.g2-exit:hover { background: rgba(255,255,255,.05); }
.g2-exit-icon { 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; }
.g2-exit:hover .g2-exit-icon { background: #fff; }
+145
View File
@@ -0,0 +1,145 @@
// res_proxy.js —— 本地资源代理(res.3dtank.com → 本地缓存)
//
// 为什么需要:官方 3DS 贴图(BG1 1024.PNG / SKY1.PNG 等)没有 CORS 头,
// 浏览器从 localhost 直接请求会被拦截;同时每次开发都走公网很慢。
//
// 用法:node tools/res_proxy.js [--port 8082] [--cache build/res-cache]
// GET /res/<path> → https://res.3dtank.com/<path>,落盘缓存后返回
// GET /play/<path> → 3DTank/web/play/<path>(本地镜像,可选)
// GET /health → ok
//
// 前端通过 webpack devServer 把 /res 代理到本服务(webpack.config.d/res-proxy.js)。
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const { URL } = require('url');
const Argv = process.argv.slice(2);
const Arg = (Name, Def) => {
const I = Argv.indexOf('--' + Name);
return I >= 0 && Argv[I + 1] ? Argv[I + 1] : Def;
};
const Root = path.resolve(__dirname, '..');
const Port = Number(Arg('port', '8082'));
const CacheDir = path.resolve(Root, Arg('cache', 'build/res-cache'));
const MirrorPlay = path.resolve(Root, '..', '3DTank', 'web', 'play');
const Upstream = 'https://res.3dtank.com';
const Mime = {
'.3ds': 'application/octet-stream',
'.a3d': 'application/octet-stream',
'.webp': 'image/webp',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.json': 'application/json',
'.info': 'application/octet-stream',
'.mp3': 'audio/mpeg',
'.ktx': 'application/octet-stream',
};
fs.mkdirSync(CacheDir, { recursive: true });
function CachePath(Key) {
const Clean = Key.replace(/^\/+/, '').replace(/[^a-zA-Z0-9._/-]/g, '_').replace(/\.\./g, '_');
return path.join(CacheDir, Clean);
}
function Send(Res, Status, Type, Body) {
Res.writeHead(Status, {
'Content-Type': Type || 'application/octet-stream',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': '*',
'Cache-Control': 'public, max-age=31536000, immutable',
});
Res.end(Body);
}
async function FetchUpstream(PathAndQuery) {
const Decoded = decodeURIComponent(PathAndQuery);
const Ext = path.extname(Decoded);
const NoExt = Decoded.slice(0, Decoded.length - Ext.length);
const Candidates = [
PathAndQuery,
Decoded.toLowerCase(),
(NoExt + '.webp').toLowerCase(),
(NoExt + '.png').toLowerCase(),
(NoExt + '.webp'),
(NoExt + '.png'),
];
for (const PathTry of [...new Set(Candidates)]) {
const R = await fetch(Upstream + (PathTry.startsWith('/') ? PathTry : '/' + PathTry), {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) TankiOffline/0.1',
Accept: '*/*',
},
});
if (R.ok) {
const Body = Buffer.from(await R.arrayBuffer());
return {
Status: 200,
Type: R.headers.get('content-type') || 'application/octet-stream',
Body,
Resolved: PathTry,
};
}
}
return { Status: 404, Type: 'text/plain', Body: Buffer.from('upstream 404') };
}
const Server = http.createServer(async (Req, Res) => {
if (Req.method === 'OPTIONS') {
Send(Res, 204, 'text/plain', Buffer.alloc(0));
return;
}
const U = new URL(Req.url, 'http://localhost');
if (U.pathname === '/health') {
Send(Res, 200, 'text/plain', Buffer.from('ok'));
return;
}
// 本地镜像:/play/... → 3DTank/web/play/...
if (U.pathname.startsWith('/play/')) {
const File = path.join(MirrorPlay, U.pathname.slice('/play/'.length));
if (File.startsWith(MirrorPlay) && fs.existsSync(File) && fs.statSync(File).isFile()) {
Send(Res, 200, Mime[path.extname(File).toLowerCase()], fs.readFileSync(File));
return;
}
Send(Res, 404, 'text/plain', Buffer.from('mirror miss'));
return;
}
// 线上资源:/res/<path> → res.3dtank.com/<path>
if (!U.pathname.startsWith('/res/')) {
Send(Res, 404, 'text/plain', Buffer.from('not found'));
return;
}
const Key = U.pathname.slice('/res'.length);
const Cache = CachePath(Key);
if (fs.existsSync(Cache) && fs.statSync(Cache).isFile()) {
Send(Res, 200, Mime[path.extname(Cache).toLowerCase()] || 'application/octet-stream', fs.readFileSync(Cache));
return;
}
try {
const Result = await FetchUpstream(Key);
if (Result.Status === 200) {
fs.mkdirSync(path.dirname(Cache), { recursive: true });
fs.writeFileSync(Cache, Result.Body);
}
Send(Res, Result.Status, Result.Type, Result.Body);
console.log(`${Result.Status === 200 ? 'cache' : 'fail '} ${Key} (${Result.Body.length}B)`);
} catch (E) {
Send(Res, 502, 'text/plain', Buffer.from('proxy error: ' + E.message));
console.log('error', Key, E.message);
}
});
Server.listen(Port, '127.0.0.1', () => {
console.log(`res proxy: http://127.0.0.1:${Port}/res/... (cache: ${CacheDir})`);
console.log(`play mirror: http://127.0.0.1:${Port}/play/... (${MirrorPlay})`);
});
@@ -0,0 +1,10 @@
// res-proxy.js —— 开发服务器把 /res/* 转给本地资源代理(tools/res_proxy.js)
config.devServer = config.devServer || {};
config.devServer.proxy = (config.devServer.proxy || []).concat([
{
context: ['/res'],
target: 'http://127.0.0.1:8082',
changeOrigin: true,
pathRewrite: { '^/res': '' },
},
]);