2026-07-08 01:24:33 +08:00
|
|
|
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';
|
2026-07-08 01:24:33 +08:00
|
|
|
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',
|
2026-07-08 01:24:33 +08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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-13 22:49:52 +08:00
|
|
|
const headers = {
|
2026-07-14 00:16:34 +08:00
|
|
|
'Content-Type': extType(originalCompressedPath(fp)),
|
|
|
|
|
'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;
|
|
|
|
|
headers['Cache-Control'] = 'public, max-age=604800';
|
|
|
|
|
} else if (/\.(mp4|m4v|webm|ogg)$/i.test(fp)) {
|
2026-07-13 22:49:52 +08:00
|
|
|
headers['Cache-Control'] = 'public, max-age=604800';
|
|
|
|
|
} else if (fp.endsWith('.loader.js')) {
|
|
|
|
|
headers['Cache-Control'] = 'no-cache';
|
|
|
|
|
} else {
|
|
|
|
|
headers['Cache-Control'] = 'public, max-age=3600';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return headers;
|
|
|
|
|
}
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 01:24:33 +08:00
|
|
|
// 首页:基于原始 index.html,注入启动封面(不修改任何 Unity 加载逻辑)
|
|
|
|
|
function serveIndex(res) {
|
|
|
|
|
try {
|
|
|
|
|
let html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf-8');
|
|
|
|
|
|
|
|
|
|
// 注入到 </head> 之前的 CSS
|
|
|
|
|
const css = `
|
|
|
|
|
<style>
|
|
|
|
|
#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); }
|
|
|
|
|
#landing-btn .arrow {
|
|
|
|
|
display: inline-block; font-size: 1.2em;
|
|
|
|
|
transition: transform 0.2s ease;
|
|
|
|
|
}
|
|
|
|
|
#landing-btn:hover .arrow { transform: translateX(4px); }
|
|
|
|
|
#landing-overlay .hint {
|
|
|
|
|
margin-top: 40px; font-size: 13px;
|
|
|
|
|
color: rgba(255,255,255,0.25); letter-spacing: 0.05em;
|
|
|
|
|
}
|
|
|
|
|
</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>
|
|
|
|
|
<button id="landing-btn" onclick="enterExperiment()">
|
|
|
|
|
进入全屏实验
|
|
|
|
|
<span class="arrow">→</span>
|
|
|
|
|
</button>
|
|
|
|
|
<p class="hint">点击按钮进入全屏沉浸式实验环境</p>
|
|
|
|
|
</div>`;
|
|
|
|
|
|
|
|
|
|
// 注入到 </body> 之前的 JS(不修改原始 Unity 加载逻辑)
|
|
|
|
|
const js = `
|
|
|
|
|
<script>
|
|
|
|
|
(function() {
|
|
|
|
|
var overlay = document.getElementById('landing-overlay');
|
|
|
|
|
|
|
|
|
|
window.enterExperiment = function() {
|
|
|
|
|
var el = document.documentElement;
|
|
|
|
|
var requestFS = el.requestFullscreen || el.webkitRequestFullscreen
|
|
|
|
|
|| el.msRequestFullscreen || el.mozRequestFullScreen;
|
|
|
|
|
|
|
|
|
|
function go() {
|
|
|
|
|
overlay.classList.add('hidden');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (requestFS) {
|
|
|
|
|
requestFS.call(el).then(go).catch(function(err) {
|
|
|
|
|
console.warn('全屏请求失败:', err);
|
|
|
|
|
go();
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
go();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
})();
|
|
|
|
|
</script>`;
|
|
|
|
|
|
|
|
|
|
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);
|
2026-07-08 01:24:33 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const headers = headersForFile(fp, end - start + 1, encoding);
|
|
|
|
|
headers['Content-Range'] = `bytes ${start}-${end}/${stat.size}`;
|
|
|
|
|
res.writeHead(206, headers);
|
|
|
|
|
fs.createReadStream(fp, { start, end }).pipe(res);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res.writeHead(200, headersForFile(fp, stat.size, encoding));
|
|
|
|
|
fs.createReadStream(fp).pipe(res);
|
|
|
|
|
});
|
2026-07-08 01:24:33 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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, () => {
|
2026-07-08 01:24:33 +08:00
|
|
|
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 + ' ║');
|
2026-07-08 01:24:33 +08:00
|
|
|
console.log(' ║ Press Ctrl+C to stop ║');
|
|
|
|
|
console.log(' ╚══════════════════════════════════════════════════╝');
|
|
|
|
|
console.log('');
|
|
|
|
|
});
|