Files
VFSUnity/Build/server.js
T

453 lines
16 KiB
JavaScript
Raw Normal View History

const http = require('http');
const fs = require('fs');
const path = require('path');
2026-07-12 19:38:16 +08:00
const PORT = process.env.PORT || 24710;
2026-07-12 20:55:44 +08:00
const HOST = process.env.HOST || '0.0.0.0';
const ROOT = __dirname;
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript',
'.wasm': 'application/wasm',
'.data': 'application/octet-stream',
'.css': 'text/css',
'.png': 'image/png',
'.ico': 'image/x-icon',
'.svg': 'image/svg+xml',
'.json': 'application/json',
'.jpg': 'image/jpeg',
2026-07-14 00:16:34 +08:00
'.jpeg': 'image/jpeg',
'.mp4': 'video/mp4',
'.m4v': 'video/mp4',
'.webm': 'video/webm',
'.ogg': 'video/ogg',
};
function extType(p) {
return MIME[path.extname(p).toLowerCase()] || 'application/octet-stream';
}
2026-07-14 00:16:34 +08:00
function originalCompressedPath(fp) {
if (fp.endsWith('.unityweb')) return fp.slice(0, -'.unityweb'.length);
if (fp.endsWith('.gz')) return fp.slice(0, -'.gz'.length);
if (fp.endsWith('.br')) return fp.slice(0, -'.br'.length);
return fp;
}
function headersForFile(fp, size, encoding) {
2026-07-14 02:03:44 +08:00
const originalPath = originalCompressedPath(fp);
const fileName = path.basename(originalPath);
const isHashedBuildAsset = /\/Build\/[a-f0-9]{32}\./.test(fp);
2026-07-13 22:49:52 +08:00
const headers = {
2026-07-14 02:03:44 +08:00
'Content-Type': extType(originalPath),
2026-07-14 00:16:34 +08:00
'Content-Length': size,
2026-07-13 22:49:52 +08:00
'X-Content-Type-Options': 'nosniff',
2026-07-14 00:16:34 +08:00
'Accept-Ranges': 'bytes',
2026-07-13 22:49:52 +08:00
};
2026-07-14 00:16:34 +08:00
if (encoding != null) {
headers['Content-Encoding'] = encoding;
2026-07-14 02:03:44 +08:00
headers['Cache-Control'] = isHashedBuildAsset
? 'public, max-age=31536000, immutable'
: 'public, max-age=604800';
2026-07-14 00:16:34 +08:00
} else if (/\.(mp4|m4v|webm|ogg)$/i.test(fp)) {
2026-07-14 02:03:44 +08:00
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
} else if (fileName === 'ServiceWorker.js' || fileName === 'index.html' || fileName === 'manifest.webmanifest') {
2026-07-13 22:49:52 +08:00
headers['Cache-Control'] = 'no-cache';
2026-07-14 02:03:44 +08:00
} else if (isHashedBuildAsset) {
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
2026-07-13 22:49:52 +08:00
} else {
headers['Cache-Control'] = 'public, max-age=3600';
}
return headers;
}
2026-07-14 02:03:44 +08:00
function addValidators(headers, stat) {
headers['ETag'] = `"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
headers['Last-Modified'] = stat.mtime.toUTCString();
return headers;
}
function isFresh(req, headers) {
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && ifNoneMatch === headers['ETag']) return true;
const ifModifiedSince = req.headers['if-modified-since'];
if (ifModifiedSince && headers['Last-Modified']) {
return new Date(ifModifiedSince).getTime() >= new Date(headers['Last-Modified']).getTime();
}
return false;
}
2026-07-14 00:16:34 +08:00
function detectUnityEncoding(fp, callback) {
if (fp.endsWith('.gz')) {
callback(null, 'gzip');
return;
}
if (fp.endsWith('.br')) {
callback(null, 'br');
return;
}
if (!fp.endsWith('.unityweb')) {
callback(null, null);
return;
}
fs.open(fp, 'r', (openErr, fd) => {
if (openErr) { callback(openErr); return; }
const buf = Buffer.alloc(2);
fs.read(fd, buf, 0, 2, 0, (readErr) => {
fs.close(fd, () => {});
if (readErr) { callback(readErr); return; }
callback(null, buf[0] === 0x1f && buf[1] === 0x8b ? 'gzip' : 'br');
});
});
}
// 首页:基于原始 index.html,注入启动封面(不修改任何 Unity 加载逻辑)
function serveIndex(res) {
try {
let html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf-8');
// 注入到 </head> 之前的 CSS
const css = `
<style>
2026-07-14 03:14:46 +08:00
html, body {
width: 100%;
height: 100%;
2026-07-17 05:42:52 +08:00
padding: 0;
margin: 0;
2026-07-14 03:14:46 +08:00
overflow: hidden;
2026-07-17 05:42:52 +08:00
background: #000;
overscroll-behavior: none;
touch-action: none;
2026-07-14 03:14:46 +08:00
}
2026-07-17 05:42:52 +08:00
body {
2026-07-14 03:14:46 +08:00
position: fixed;
inset: 0;
2026-07-17 05:42:52 +08:00
}
#unity-container {
position: fixed;
left: 50%;
top: 50%;
width: min(100vw, calc(100vh * 16 / 9));
height: min(100vh, calc(100vw * 9 / 16));
transform: translate(-50%, -50%);
overflow: hidden;
background: #000;
2026-07-14 03:14:46 +08:00
}
#unity-canvas {
display: block;
2026-07-17 05:42:52 +08:00
width: 100% !important;
height: 100% !important;
background: #231f20;
touch-action: none;
}
@supports (height: 100dvh) {
#unity-container {
width: min(100vw, calc(100dvh * 16 / 9));
height: min(100dvh, calc(100vw * 9 / 16));
}
2026-07-14 03:14:46 +08:00
}
#unity-loading-bar {
left: 50% !important;
top: 50% !important;
transform: translate(-50%, -50%) !important;
}
#landing-overlay {
position: fixed; inset: 0; z-index: 9999;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
background: radial-gradient(ellipse at center, #2a2526 0%, #1a1516 70%, #0d0a0b 100%);
transition: opacity 0.5s ease;
}
#landing-overlay.hidden { opacity: 0; pointer-events: none; }
#landing-overlay .logo-area { margin-bottom: 60px; text-align: center; }
#landing-overlay .logo-area .icon {
width: 120px; height: 120px; margin: 0 auto 30px;
background: url('TemplateData/unity-logo-light.png') no-repeat center;
background-size: contain;
filter: drop-shadow(0 0 30px rgba(255,255,255,0.15));
}
#landing-overlay h1 {
font-size: clamp(24px, 4vw, 42px);
font-weight: 700; letter-spacing: 0.08em; color: #fff;
text-shadow: 0 2px 20px rgba(255,255,255,0.2);
}
#landing-overlay .subtitle {
margin-top: 12px;
font-size: clamp(14px, 1.8vw, 18px);
color: rgba(255,255,255,0.45); letter-spacing: 0.15em;
}
#landing-btn {
display: inline-flex; align-items: center; gap: 12px;
padding: 16px 56px;
font-size: clamp(16px, 2vw, 20px);
font-weight: 600; letter-spacing: 0.1em; color: #fff;
background: linear-gradient(135deg, #4a90d9 0%, #357abd 100%);
border: none; border-radius: 50px; cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
box-shadow: 0 8px 32px rgba(74, 144, 217, 0.35);
}
#landing-btn:hover {
transform: translateY(-2px) scale(1.03);
box-shadow: 0 12px 40px rgba(74, 144, 217, 0.5);
}
#landing-btn:active { transform: translateY(0) scale(0.98); }
2026-07-14 03:14:46 +08:00
#landing-btn:disabled {
cursor: default;
opacity: 0.48;
transform: none;
box-shadow: none;
background: linear-gradient(135deg, #4b5563 0%, #374151 100%);
}
#landing-progress {
width: min(420px, 72vw);
height: 8px;
margin-top: 28px;
border-radius: 999px;
overflow: hidden;
background: rgba(255,255,255,0.14);
}
#landing-progress-fill {
width: 0%;
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, #4a90d9 0%, #7dd3fc 100%);
transition: width 0.2s ease;
}
#landing-progress-text {
margin-top: 12px;
min-height: 18px;
font-size: 13px;
color: rgba(255,255,255,0.52);
letter-spacing: 0.08em;
}
#landing-btn .arrow {
display: inline-block; font-size: 1.2em;
transition: transform 0.2s ease;
}
#landing-btn:hover .arrow { transform: translateX(4px); }
2026-07-14 03:14:46 +08:00
#landing-btn:disabled .arrow { transform: none; }
#landing-overlay .hint {
margin-top: 40px; font-size: 13px;
color: rgba(255,255,255,0.25); letter-spacing: 0.05em;
}
2026-07-14 03:14:46 +08:00
@media (orientation: portrait) {
#landing-overlay .logo-area { margin-bottom: 34px; }
#landing-overlay .logo-area .icon { width: 84px; height: 84px; margin-bottom: 22px; }
#landing-btn { padding: 14px 32px; }
#landing-overlay .hint { margin-top: 24px; }
}
</style>`;
// 注入到 <body> 最前面的 overlay
const overlay = `
<div id="landing-overlay">
<div class="logo-area">
<div class="icon"></div>
<h1>运动伤害现场急救虚拟仿真实验</h1>
<p class="subtitle">VIRTUAL SIMULATION EXPERIMENT</p>
</div>
2026-07-14 03:14:46 +08:00
<button id="landing-btn" onclick="enterExperiment()" disabled>
进入全屏实验
<span class="arrow">→</span>
</button>
2026-07-14 03:14:46 +08:00
<div id="landing-progress">
<div id="landing-progress-fill"></div>
</div>
<div id="landing-progress-text">加载中 0%</div>
<p class="hint">点击按钮进入全屏沉浸式实验环境</p>
</div>`;
// 注入到 </body> 之前的 JS(不修改原始 Unity 加载逻辑)
const js = `
<script>
(function() {
var overlay = document.getElementById('landing-overlay');
2026-07-14 03:14:46 +08:00
var btn = document.getElementById('landing-btn');
var fill = document.getElementById('landing-progress-fill');
var text = document.getElementById('landing-progress-text');
var ready = false;
window.__landingSetProgress = function(progress) {
var percent = Math.max(0, Math.min(100, Math.round((progress || 0) * 100)));
if (fill) fill.style.width = percent + '%';
if (text) text.textContent = percent >= 100 ? '加载完成' : '加载中 ' + percent + '%';
};
window.__landingSetReady = function() {
ready = true;
window.__landingSetProgress(1);
if (btn) btn.disabled = false;
};
window.enterExperiment = function() {
2026-07-14 03:14:46 +08:00
if (!ready) return;
var el = document.documentElement;
var requestFS = el.requestFullscreen || el.webkitRequestFullscreen
|| el.msRequestFullscreen || el.mozRequestFullScreen;
function go() {
2026-07-17 05:42:52 +08:00
if (screen.orientation && screen.orientation.lock) {
screen.orientation.lock('landscape').catch(function(err) {
console.warn('横屏锁定失败:', err);
});
}
overlay.classList.add('hidden');
}
if (requestFS) {
requestFS.call(el).then(go).catch(function(err) {
console.warn('全屏请求失败:', err);
go();
});
} else {
go();
}
};
})();
</script>`;
2026-07-14 03:14:46 +08:00
html = html.replace(
/progressBarFull\.style\.width\s*=\s*100\s*\*\s*progress\s*\+\s*"%";/,
'progressBarFull.style.width = 100 * progress + "%"; if (window.__landingSetProgress) window.__landingSetProgress(progress);'
);
2026-07-17 05:42:52 +08:00
if (!html.includes('name="viewport"')) {
html = html.replace(
'<meta http-equiv="Content-Type" content="text/html; charset=utf-8">',
'<meta http-equiv="Content-Type" content="text/html; charset=utf-8">\\n <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">'
);
}
html = html.replace(
/<canvas id="unity-canvas"[^>]*><\/canvas>/,
'<canvas id="unity-canvas" width=1920 height=1080 tabindex="-1"></canvas>'
);
2026-07-14 03:14:46 +08:00
if (!html.includes('config.cacheControl')) {
html = html.replace(
/showBanner:\s*unityShowBanner,\s*\n\s*};/,
'showBanner: unityShowBanner,\n };\n\n config.cacheControl = function (url) {\n if (url === config.dataUrl || url === config.frameworkUrl || url === config.codeUrl) {\n return "immutable";\n }\n return "must-revalidate";\n };'
);
}
2026-07-17 05:42:52 +08:00
html = html.replace(
/config\.devicePixelRatio\s*=\s*[^;]+;/,
'config.devicePixelRatio = 1;'
);
2026-07-14 03:14:46 +08:00
if (!html.includes('config.devicePixelRatio')) {
html = html.replace(
/};\s*\n\s*\/\/ By default Unity keeps WebGL canvas render target size matched with/,
2026-07-17 05:42:52 +08:00
'};\n config.devicePixelRatio = 1;\n\n // By default Unity keeps WebGL canvas render target size matched with'
2026-07-14 03:14:46 +08:00
);
}
2026-07-17 05:42:52 +08:00
if (!html.includes('config.matchWebGLToCanvasSize = false;')) {
html = html.replace(
/config\.devicePixelRatio\s*=\s*1;/,
'config.devicePixelRatio = 1;\n config.matchWebGLToCanvasSize = false;\n var UNITY_DESIGN_WIDTH = 1920;\n var UNITY_DESIGN_HEIGHT = 1080;\n function lockUnityCanvasResolution() {\n canvas.width = UNITY_DESIGN_WIDTH;\n canvas.height = UNITY_DESIGN_HEIGHT;\n }\n lockUnityCanvasResolution();\n window.addEventListener("resize", lockUnityCanvasResolution);\n window.addEventListener("orientationchange", function () { setTimeout(lockUnityCanvasResolution, 150); });\n document.addEventListener("fullscreenchange", lockUnityCanvasResolution);'
);
}
html = html.replace(
/if \(\s*\/iPhone\|iPad\|iPod\|Android\/i\.test\(navigator\.userAgent\)\s*\)\s*\{[\s\S]*?document\.getElementsByTagName\('head'\)\[0\]\.appendChild\(meta\);\s*\}/,
''
);
2026-07-14 03:14:46 +08:00
html = html.replace(
/loadingBar\.style\.display\s*=\s*"none";/,
'if (window.__landingSetReady) window.__landingSetReady(); loadingBar.style.display = "none";'
);
html = html.replace('</head>', css + '\n</head>');
html = html.replace('<body>', '<body>\n' + overlay);
html = html.replace('</body>', js + '\n</body>');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
res.end(html);
} catch (err) {
res.writeHead(500);
res.end('Internal Server Error');
console.error(err);
}
}
// 静态文件
function serveStatic(req, res) {
2026-07-14 00:16:34 +08:00
let requestPath;
try {
requestPath = decodeURIComponent(req.url.split('?')[0]);
} catch (err) {
res.writeHead(400);
res.end('Bad Request');
return;
}
let fp = path.join(ROOT, requestPath);
if (!fp.startsWith(ROOT)) { res.writeHead(403); res.end('Forbidden'); return; }
2026-07-14 00:16:34 +08:00
fs.stat(fp, (err, stat) => {
if (err || !stat.isFile()) { res.writeHead(404); res.end('Not Found'); return; }
detectUnityEncoding(fp, (encodingErr, encoding) => {
if (encodingErr) { res.writeHead(500); res.end('Internal Server Error'); return; }
const range = req.headers.range;
if (range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
if (!match) {
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` });
res.end();
return;
}
let start;
let end;
if (match[1] === '' && match[2] !== '') {
const suffixLength = parseInt(match[2], 10);
start = Math.max(stat.size - suffixLength, 0);
end = stat.size - 1;
} else {
start = match[1] === '' ? 0 : parseInt(match[1], 10);
end = match[2] === '' ? stat.size - 1 : parseInt(match[2], 10);
}
if (Number.isNaN(start) || Number.isNaN(end) || start > end || end >= stat.size) {
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` });
res.end();
return;
}
2026-07-14 02:03:44 +08:00
const headers = addValidators(headersForFile(fp, end - start + 1, encoding), stat);
2026-07-14 00:16:34 +08:00
headers['Content-Range'] = `bytes ${start}-${end}/${stat.size}`;
res.writeHead(206, headers);
fs.createReadStream(fp, { start, end }).pipe(res);
return;
}
2026-07-14 02:03:44 +08:00
const headers = addValidators(headersForFile(fp, stat.size, encoding), stat);
if (isFresh(req, headers)) {
delete headers['Content-Length'];
res.writeHead(304, headers);
res.end();
return;
}
res.writeHead(200, headers);
2026-07-14 00:16:34 +08:00
fs.createReadStream(fp).pipe(res);
});
});
}
http.createServer((req, res) => {
if (req.url === '/' || req.url === '/index.html') return serveIndex(res);
serveStatic(req, res);
2026-07-12 20:55:44 +08:00
}).listen(PORT, HOST, () => {
console.log('');
console.log(' ╔══════════════════════════════════════════════════╗');
console.log(' ║ 运动伤害现场急救虚拟仿真实验 - WebGL Server ║');
console.log(' ║ ║');
2026-07-12 20:55:44 +08:00
console.log(' ║ Server running at: http://' + HOST + ':' + PORT + ' ║');
console.log(' ║ Press Ctrl+C to stop ║');
console.log(' ╚══════════════════════════════════════════════════╝');
console.log('');
});