移动测试场 · 联机
by SwiftGalaxy492236 lines76.5 KB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0a0a0a">
<title>移动测试场 · 联机</title>
<style>
*{ -webkit-user-select:none; user-select:none; -webkit-tap-highlight-color:transparent; box-sizing:border-box; }
html,body{
margin:0; height:100%; background:#000; overflow:hidden;
font-family:"Courier New",Consolas,monospace;
touch-action:none; overscroll-behavior:none; color:#c8c8b8;
}
#stage{ position:fixed; inset:0; background:#000; overflow:hidden; }
canvas{
display:block; position:absolute; left:50%; top:50%;
transform:translate(-50%,-50%);
background:#000; image-rendering:pixelated; image-rendering:crisp-edges;
touch-action:none;
}
/* ============ 大厅 ============ */
#lobby{
position:absolute; inset:0; display:flex; flex-direction:column;
align-items:center; justify-content:center; gap:12px;
background:radial-gradient(ellipse at center, #1a2a1a 0%, #050a05 100%);
z-index:100; padding:20px;
}
#lobby.hide{display:none;}
#lobby h1{ margin:0; font-size:30px; color:#8ac85a; letter-spacing:8px;
text-shadow:0 0 20px rgba(138,200,90,.5), 3px 3px 0 #000; }
#lobby .sub{ font-size:10px; color:#6a7a5a; letter-spacing:4px; margin-top:-8px; }
#lobby .startBtn{
margin-top:4px; padding:12px 44px; font-family:inherit; font-size:14px;
font-weight:bold; letter-spacing:6px; cursor:pointer;
color:#0a0a0a; background:#8ac85a; border:none;
box-shadow:0 0 30px rgba(138,200,90,.4), 4px 4px 0 #3a5a1a;
}
#lobby .startBtn:active{ transform:translate(2px,2px); box-shadow:2px 2px 0 #3a5a1a; }
#lobby .startBtn.online{
background:#5aa8d8; color:#031018;
box-shadow:0 0 30px rgba(90,168,216,.4), 4px 4px 0 #1a4a6a;
}
#lobby .startBtn.online:active{ box-shadow:2px 2px 0 #1a4a6a; }
#lobby .lobby-tip{ font-size:9px; color:#5a6a4a; letter-spacing:2px; text-align:center; line-height:1.8; margin-top:4px; }
/* ============ 联机面板 ============ */
#onlinePanel{
position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
width:min(440px, 94vw); max-height:90vh;
background:linear-gradient(180deg, rgba(15,25,15,.99), rgba(8,15,8,.99));
border:2px solid #3a5a2a;
z-index:110; padding:14px;
display:none; flex-direction:column; gap:10px;
color:#c8e8a8; font-size:11px;
box-shadow:0 0 50px rgba(0,0,0,.95);
}
#onlinePanel.show{ display:flex; }
#onlinePanel h2{
margin:0; font-size:15px; color:#8ac85a; text-align:center;
letter-spacing:4px; border-bottom:1px solid #3a5a2a; padding-bottom:8px; flex:none;
}
.ol-name-row{ display:flex; align-items:center; gap:8px; flex:none; }
.ol-name-row label{ font-size:9px; color:#7a9a6a; letter-spacing:1px; flex:none; }
.ol-name-row input{ flex:1; }
.ol-tabs{ display:flex; gap:6px; flex:none; }
.ol-tab{
flex:1; padding:8px; font-family:inherit; font-size:11px; font-weight:bold;
letter-spacing:3px; cursor:pointer; color:#8ac85a;
background:rgba(20,30,20,.8); border:1.5px solid rgba(138,200,90,.3);
border-radius:3px;
}
.ol-tab.active{
background:rgba(60,100,40,.9); color:#fff; border-color:#8ac85a;
box-shadow:0 0 12px rgba(138,200,90,.3);
}
.ol-body{
overflow-y:auto; -webkit-overflow-scrolling:touch; touch-action:pan-y;
display:flex; flex-direction:column; gap:10px;
flex:1; min-height:0; padding-right:2px;
}
.ol-body *{ touch-action:pan-y; }
.ol-group{
border:1px solid rgba(138,200,90,.25); border-radius:4px; padding:10px;
display:flex; flex-direction:column; gap:7px; background:rgba(20,30,20,.4);
}
.ol-group .sg-title{
font-size:10px; color:#8ac85a; letter-spacing:3px; font-weight:bold;
border-bottom:1px dashed rgba(138,200,90,.25); padding-bottom:5px;
}
.ol-input{
width:100%; padding:8px 10px; font-family:inherit; font-size:11px;
background:rgba(0,0,0,.5); color:#d8e8b8; border:1px solid #3a5a2a;
border-radius:3px; outline:none;
-webkit-user-select:text; user-select:text;
}
.ol-input:focus{ border-color:#8ac85a; }
.ol-input::placeholder{ color:#5a6a4a; }
.ol-row{ display:flex; align-items:center; gap:8px; font-size:10px; color:#a8c898; }
.ol-row label{ flex:none; letter-spacing:1px; }
.ol-row input[type=range]{ flex:1; }
.ol-row span{ width:32px; text-align:right; color:#ffd75e; font-weight:bold; flex:none; }
.ol-select{
flex:1; padding:7px 8px; font-family:inherit; font-size:11px;
background:rgba(0,0,0,.5); color:#d8e8b8; border:1px solid #3a5a2a;
border-radius:3px; outline:none;
}
.ol-btn{
padding:9px; font-family:inherit; font-size:11px; font-weight:bold;
letter-spacing:2px; cursor:pointer; color:#c8e8a8;
background:rgba(40,60,30,.9); border:1.5px solid rgba(138,200,90,.4);
border-radius:3px;
}
.ol-btn:active{ background:rgba(80,120,50,.95); }
.ol-btn.primary{ background:#8ac85a; color:#0a0a0a; border-color:#8ac85a;
box-shadow:2px 2px 0 #3a5a1a; }
.ol-btn.primary:active{ transform:translate(1px,1px); box-shadow:1px 1px 0 #3a5a1a; }
.ol-btn.back{ flex:none; background:rgba(40,50,40,.9); color:#a8b898; }
.ol-list{
display:flex; flex-direction:column; gap:5px; min-height:30px;
}
.ol-empty{ font-size:9px; color:#5a6a4a; text-align:center; padding:8px; letter-spacing:1px; }
.ol-item{
display:flex; align-items:center; gap:8px;
background:rgba(30,45,30,.7); border:1px solid rgba(138,200,90,.25);
border-radius:3px; padding:7px 9px;
}
.ol-item-main{ flex:1; min-width:0; }
.ol-item-name{ font-size:11px; color:#d8e8b8; font-weight:bold;
overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.ol-item-sub{ font-size:8px; color:#7a9a6a; margin-top:2px; letter-spacing:.5px; }
.ol-mini{
padding:5px 10px; font-family:inherit; font-size:9px; font-weight:bold;
letter-spacing:1px; cursor:pointer; color:#0a0a0a; background:#8ac85a;
border:none; border-radius:3px; flex:none;
}
.ol-mini:active{ background:#a8e87a; }
.ol-msg{
font-size:9px; color:#ffd75e; letter-spacing:1px; text-align:center;
min-height:14px; flex:none; line-height:1.4;
}
.ol-msg.err{ color:#ff7a5a; }
.ol-note{
font-size:8px; color:#5a6a4a; text-align:center; letter-spacing:1px;
line-height:1.5; flex:none;
}
/* ============ 游戏 ============ */
#game{ position:absolute; inset:0; display:none; }
#game.show{ display:block; }
#hud{
position:absolute; left:0; top:0; right:0;
display:flex; justify-content:space-between; align-items:flex-start;
padding:5px 96px 5px 8px;
color:#c8e8a8; font-size:10px; font-weight:bold;
text-shadow:1px 1px 0 #000; letter-spacing:1px;
pointer-events:none; flex-wrap:wrap; gap:3px; z-index:10;
}
#hud b{color:#fff; font-size:12px;}
#hud .grp{ background:rgba(0,0,0,.35); padding:2px 7px; border-radius:2px; border-left:2px solid #8ac85a; }
#hud .grp.net{ border-left-color:#5aa8d8; }
#hud .grp.net b{ color:#8ad0ff; }
#centerMsg{
position:absolute; left:50%; top:38%; transform:translateX(-50%);
color:#fff; font-size:12px; font-weight:bold; letter-spacing:2px;
text-shadow:1px 1px 0 #000; background:rgba(0,0,0,.55);
padding:5px 16px; border-radius:2px; pointer-events:none;
opacity:0; transition:opacity .3s; white-space:nowrap; z-index:10;
}
#centerMsg.show{opacity:1;}
#setBtn{
position:absolute; top:8px; right:8px;
width:38px; height:38px; border-radius:50%;
background:rgba(10,20,10,.7);
border:1.5px solid rgba(138,200,90,.5);
color:#c8e8a8; font-family:inherit; font-size:14px; font-weight:bold;
pointer-events:none; z-index:15;
display:flex; align-items:center; justify-content:center;
cursor:pointer;
}
body.playing #setBtn{ pointer-events:auto; }
#setBtn:active{ background:rgba(60,90,40,.8); }
#netLeaveBtn{
position:absolute; top:8px; right:52px;
height:30px; padding:0 12px; border-radius:5px;
background:rgba(60,20,20,.85);
border:1.5px solid rgba(255,120,90,.6);
color:#ffb0a0; font-family:inherit; font-size:10px; font-weight:bold;
letter-spacing:1px; pointer-events:none; z-index:15;
display:none; align-items:center; justify-content:center;
cursor:pointer;
}
body.playing #netLeaveBtn.show{ pointer-events:auto; display:flex; }
#netLeaveBtn:active{ background:rgba(120,40,30,.95); }
/* ============ 设置面板 ============ */
#setPanel{
position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
width:min(380px, 94vw); max-height:88vh;
background:rgba(10,18,10,.97);
border:2px solid #3a5a2a;
z-index:60; padding:14px;
display:none; flex-direction:column; gap:10px;
color:#c8e8a8; font-size:11px;
}
#setPanel.show{ display:flex; }
#setPanel h2{ margin:0; font-size:15px; color:#8ac85a; text-align:center;
letter-spacing:4px; border-bottom:1px solid #3a5a2a; padding-bottom:8px; flex:none; }
.set-group{ border:1px solid rgba(138,200,90,.25); border-radius:4px; padding:10px;
display:flex; flex-direction:column; gap:8px; background:rgba(20,30,20,.4); }
.sg-title2{ font-size:10px; color:#8ac85a; letter-spacing:3px; font-weight:bold;
border-bottom:1px dashed rgba(138,200,90,.25); padding-bottom:5px; }
.set-item{ display:flex; flex-direction:column; gap:4px; }
.set-item label{ font-size:10px; color:#a8c898; letter-spacing:1px; display:flex; justify-content:space-between; }
.set-item .val{ color:#ffd75e; font-weight:bold; }
input[type=range]{ width:100%; height:6px; background:rgba(60,90,50,.5);
border-radius:3px; outline:none; -webkit-appearance:none; }
input[type=range]::-webkit-slider-thumb{
-webkit-appearance:none; width:18px; height:18px;
background:#8ac85a; border-radius:50%; cursor:pointer; border:2px solid #3a5a1a; }
#setPanel .closeBtn{
flex:none; padding:9px; font-family:inherit; font-size:12px; font-weight:bold;
letter-spacing:3px; background:#8ac85a; color:#0a0a0a; border:none;
cursor:pointer; box-shadow:3px 3px 0 #3a5a1a; }
#setPanel .closeBtn:active{ transform:translate(2px,2px); box-shadow:1px 1px 0 #3a5a1a; }
/* ============ 触屏UI ============ */
#touchUI{ position:absolute; inset:0; display:none; pointer-events:none; z-index:20; }
body.touch.playing #touchUI{ display:block; }
.stick{
position:absolute; width:94px; height:94px; border-radius:50%;
background:rgba(255,255,255,.05);
border:2px solid rgba(200,220,180,.2);
pointer-events:auto; touch-action:none;
box-shadow:inset 0 0 18px rgba(0,0,0,.6);
}
.knob{
position:absolute; left:50%; top:50%;
width:38px; height:38px; margin:-19px 0 0 -19px;
border-radius:50%;
background:radial-gradient(circle at 35% 30%, rgba(200,230,180,.9), rgba(90,140,60,.8));
box-shadow:0 0 10px rgba(138,200,90,.5);
will-change:transform;
}
.actionBtn{
position:absolute; border-radius:50%; pointer-events:auto; touch-action:none;
font-family:inherit; font-weight:bold; letter-spacing:1px;
text-shadow:1px 1px 0 #000; color:#fff;
display:flex; align-items:center; justify-content:center; font-size:11px;
padding:0; outline:none; -webkit-appearance:none;
}
#jumpBtn{ background:radial-gradient(circle at 35% 30%, rgba(120,180,255,.8), rgba(20,60,150,.75));
border:2px solid rgba(150,200,255,.6); }
#jumpBtn.on{ background:radial-gradient(circle at 35% 30%, #a0d0ff, #2a5ab0); }
#crouchBtn{ background:radial-gradient(circle at 35% 30%, rgba(180,200,120,.8), rgba(80,110,40,.75));
border:2px solid rgba(180,220,140,.6); }
#crouchBtn.on{ background:radial-gradient(circle at 35% 30%, #d0e890, #7a9a3a); }
#proneBtn{ background:radial-gradient(circle at 35% 30%, rgba(160,140,200,.8), rgba(70,50,110,.75));
border:2px solid rgba(190,170,220,.6); }
#proneBtn.on{ background:radial-gradient(circle at 35% 30%, #c0a8e0, #5a4a8a); }
#rotateHint{
position:absolute; inset:0; background:rgba(0,0,0,.94);
color:#8ac85a; align-items:center; justify-content:center;
z-index:999; font-size:14px; letter-spacing:3px; text-align:center;
padding:20px;
}
@media (orientation:landscape) and (max-height:500px){
#setBtn{ width:34px; height:34px; font-size:12px; }
#netLeaveBtn{ height:26px; font-size:9px; right:46px; }
#hud{ padding-right:88px; }
}
</style>
</head>
<body>
<div id="stage">
<div id="rotateHint" style="display:none;">请 将 手 机 横 屏 使 用</div>
<div id="lobby">
<h1>移 动 测 试 场</h1>
<div class="sub">PSEUDO-3D · MOVEMENT</div>
<button class="startBtn" id="startBtn">单 机 进 入</button>
<button class="startBtn online" id="onlineBtn">联 机</button>
<div class="lobby-tip">
WASD 移动 · 空格 跳 · C 蹲 · Z 趴<br>
滑屏 / 鼠标 转视角 · 可跳上矮墙
</div>
</div>
<!-- ============ 联机面板 ============ -->
<div id="onlinePanel">
<h2>联 机</h2>
<div class="ol-name-row">
<label>我的名字</label>
<input class="ol-input" id="playerNameInput" maxlength="12" placeholder="玩家名称">
</div>
<div class="ol-tabs">
<button class="ol-tab active" data-tab="room">房 间</button>
<button class="ol-tab" data-tab="server">服 务 器</button>
</div>
<!-- 房间 -->
<div class="ol-body" id="olBodyRoom">
<div class="ol-group">
<div class="sg-title">创 建 房 间</div>
<input class="ol-input" id="roomCreateName" maxlength="12" placeholder="房间名(可选)">
<input class="ol-input" id="roomCreatePass" type="password" maxlength="16" placeholder="房间密码(留空则不设密码)">
<div class="ol-row">
<label>人数上限</label>
<input type="range" id="roomCreateMax" min="2" max="10" step="1" value="10">
<span id="roomCreateMaxVal">10</span>
</div>
<button class="ol-btn primary" id="roomCreateBtn">创 建 房 间</button>
</div>
<div class="ol-group">
<div class="sg-title">加 入 房 间</div>
<input class="ol-input" id="roomJoinId" maxlength="6" inputmode="numeric" placeholder="房间号(6 位数字)">
<input class="ol-input" id="roomJoinPass" type="password" maxlength="16" placeholder="房间密码(无密码留空)">
<button class="ol-btn" id="roomJoinBtn">加 入 房 间</button>
</div>
<div class="ol-group">
<div class="sg-title">活 跃 房 间</div>
<div class="ol-list" id="roomList"><div class="ol-empty">— 暂无活跃房间 —</div></div>
</div>
</div>
<!-- 服务器 -->
<div class="ol-body" id="olBodyServer" style="display:none;">
<div class="ol-group">
<div class="sg-title">创 建 服 务 器</div>
<input class="ol-input" id="serverCreateName" maxlength="16" placeholder="服务器名">
<input class="ol-input" id="serverCreateIp" maxlength="32" placeholder="服务器 IP(如 10.12.34.1:19132)">
<input class="ol-input" id="serverCreatePass" type="password" maxlength="16" placeholder="服务器密码(留空则不设密码)">
<div class="ol-row">
<label>人数上限</label>
<select class="ol-select" id="serverCreateMax">
<option value="10">10 人</option>
<option value="20">20 人</option>
<option value="32">32 人</option>
<option value="64">64 人</option>
</select>
</div>
<button class="ol-btn primary" id="serverCreateBtn">创 建 服 务 器</button>
</div>
<div class="ol-group">
<div class="sg-title">加 入 服 务 器</div>
<input class="ol-input" id="serverJoinIp" maxlength="32" placeholder="服务器 IP 地址">
<input class="ol-input" id="serverJoinPass" type="password" maxlength="16" placeholder="服务器密码(无密码留空)">
<button class="ol-btn" id="serverJoinBtn">加 入 服 务 器</button>
</div>
<div class="ol-group">
<div class="sg-title">已 注 册 服 务 器</div>
<div class="ol-list" id="serverList"><div class="ol-empty">— 暂无服务器 —</div></div>
</div>
</div>
<div class="ol-msg" id="onlineMsg"></div>
<div class="ol-note">本地联机模拟 · 同一浏览器多标签页间通信<br>服务器数据永久保存在本机</div>
<button class="ol-btn back" id="onlineBack">返 回 大 厅</button>
</div>
<!-- ============ 游戏 ============ -->
<div id="game">
<canvas id="screen"></canvas>
<div id="hud">
<div class="grp">高度 <b id="hHeight">0.00</b></div>
<div class="grp">姿态 <b id="hPose">站立</b></div>
<div class="grp">速度 <b id="hSpeed">0.0</b></div>
<div class="grp net" id="netGrp" style="display:none;">联机 <b id="netInfo">—</b></div>
</div>
<div id="centerMsg"></div>
<div id="setBtn">⚙</div>
<button id="netLeaveBtn">离开联机</button>
<div id="touchUI">
<div class="stick" id="moveStick"><div class="knob"></div></div>
<button class="actionBtn" id="jumpBtn">跳跃</button>
<button class="actionBtn" id="crouchBtn">蹲伏</button>
<button class="actionBtn" id="proneBtn">趴下</button>
</div>
<div id="setPanel">
<h2>设 置</h2>
<div class="set-group">
<div class="sg-title2">视 角</div>
<div class="set-item">
<label>水平灵敏度 <span class="val" id="sensHipVal">0.0075</span></label>
<input type="range" id="sensHip" min="0.002" max="0.02" step="0.0005" value="0.0075">
</div>
<div class="set-item">
<label>纵向灵敏度 <span class="val" id="sensYVal">0.55</span></label>
<input type="range" id="sensY" min="0.2" max="1.5" step="0.05" value="0.55">
</div>
<div class="set-item">
<label>俯仰范围 <span class="val" id="pitchRangeVal">90°</span></label>
<input type="range" id="pitchRange" min="60" max="90" step="1" value="90">
</div>
</div>
<button class="closeBtn" id="setClose">关 闭</button>
</div>
</div>
</div>
<script>
"use strict";
/* ============================================================
1. 基础常量
============================================================ */
const IS_TOUCH = ('ontouchstart' in window) || navigator.maxTouchPoints > 0;
if (IS_TOUCH) document.body.classList.add('touch');
const W = 320, H = 240;
const FOV_PLANE = 0.66;
const MAP_SIZE = 40;
const EYE_STAND = 0.5, EYE_CROUCH = 0.32, EYE_PRONE = 0.18;
const GRAVITY = 26;
const MAX_FALL = 32;
const MOVE_SPEED = 3.2;
const PLAYER_R = 0.28;
const canvas = document.getElementById('screen');
canvas.width = W; canvas.height = H;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
const $ = id => document.getElementById(id);
const stageEl = $('stage'), lobbyEl = $('lobby');
const centerMsg = $('centerMsg'), setPanel = $('setPanel');
const setBtn = $('setBtn'), moveStickEl = $('moveStick');
const onlinePanel = $('onlinePanel'), onlineMsg = $('onlineMsg');
const keys = Object.create(null);
/* ============================================================
2. 设置持久化
============================================================ */
const settings = { sensHip: 0.0075, sensY: 0.55, pitchRange: 90 };
function saveSettings(){ try{ localStorage.setItem('jjtw_move_settings', JSON.stringify(settings)); }catch(_){} }
function loadSettings(){
try{
const s = localStorage.getItem('jjtw_move_settings');
if (s) Object.assign(settings, JSON.parse(s));
}catch(_){}
}
loadSettings();
/* ============================================================
3. 通用工具
============================================================ */
function getViewport(){
if (window.visualViewport) return { w: window.visualViewport.width, h: window.visualViewport.height };
return { w: window.innerWidth, h: window.innerHeight };
}
function showMsg(text, dur){
centerMsg.textContent = text;
centerMsg.classList.add('show');
clearTimeout(showMsg._t);
showMsg._t = setTimeout(() => centerMsg.classList.remove('show'), dur || 2000);
}
function escapeHtml(s){
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
}
function hashPass(s){
let h = 0;
for (let i = 0; i < s.length; i++){
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
}
return 'h' + (h >>> 0).toString(36);
}
function genRoomId(){
let id;
let guard = 0;
do {
id = String(Math.floor(Math.random()*900000+100000));
guard++;
} while (guard < 50 && activeRooms.has(id) && performance.now() - activeRooms.get(id).lastSeen < 3500);
return id;
}
function genServerIp(){
const a = Math.floor(Math.random()*200+10);
const b = Math.floor(Math.random()*200+10);
return '10.' + a + '.' + b + '.1:19132';
}
/* ============================================================
4. 纹理(伪3D 墙面贴图)
============================================================ */
function makeTexture(fn){
const c = document.createElement('canvas');
c.width = 64; c.height = 64;
fn(c.getContext('2d'));
return c;
}
function noise(g, n, a){
for (let i = 0; i < n; i++){
g.fillStyle = 'rgba(0,0,0,' + (Math.random()*a).toFixed(3) + ')';
g.fillRect(Math.random()*64|0, Math.random()*64|0, 2, 2);
}
}
const textures = [null];
textures[1] = makeTexture(g => {
g.fillStyle = '#2a2620'; g.fillRect(0,0,64,64);
for (let i = 0; i < 60; i++){
const x = Math.random()*64, y = Math.random()*64, r = 4 + Math.random()*10;
const v = 0.6 + Math.random()*0.5;
g.fillStyle = 'rgb(' + (70*v|0) + ',' + (62*v|0) + ',' + (50*v|0) + ')';
g.beginPath(); g.arc(x, y, r, 0, Math.PI*2); g.fill();
}
noise(g, 300, 0.3);
});
textures[2] = makeTexture(g => {
g.fillStyle = '#3a2820'; g.fillRect(0,0,64,64);
for (let row = 0; row < 8; row++){
const off = (row % 2) ? 8 : 0;
for (let i = -1; i < 5; i++){
const x = i*16 + off, y = row*8, v = 0.75 + Math.random()*0.4;
g.fillStyle = 'rgb(' + (120*v|0) + ',' + (72*v|0) + ',' + (48*v|0) + ')';
g.fillRect(x+1, y+1, 14, 6);
}
}
g.fillStyle = '#1a2a3a'; g.fillRect(20,14,24,18);
g.fillStyle = '#3a6a8a'; g.fillRect(22,16,20,14);
g.fillStyle = '#0a0a0a'; g.fillRect(31,14,2,18); g.fillRect(20,22,24,2);
g.fillStyle = '#5a4030'; g.fillRect(0,0,64,4);
noise(g, 180, 0.2);
});
textures[3] = makeTexture(g => {
g.fillStyle = '#1a2028'; g.fillRect(0,0,64,64);
g.fillStyle = '#2a3848'; g.fillRect(2,2,60,60);
for (let x = 4; x < 60; x += 8){
g.fillStyle = '#1a2430'; g.fillRect(x,4,2,56);
g.fillStyle = '#3a5068'; g.fillRect(x+2,4,2,56);
}
g.fillStyle = '#4a6480'; g.fillRect(4,4,56,2); g.fillRect(4,58,56,2);
g.fillStyle = '#8ac85a'; g.font = 'bold 10px monospace';
g.fillText('C-' + (Math.random()*900+100|0), 8, 36);
noise(g, 120, 0.15);
});
textures[4] = makeTexture(g => {
g.fillStyle = '#2a1a0a'; g.fillRect(0,0,64,64);
for (let x = 0; x < 64; x += 8){
const v = 0.8 + Math.random()*0.4;
g.fillStyle = 'rgb(' + (140*v|0) + ',' + (95*v|0) + ',' + (50*v|0) + ')';
g.fillRect(x+1, 0, 6, 64);
}
g.fillStyle = '#1a1004'; g.fillRect(0,20,64,2); g.fillRect(0,44,64,2);
g.fillStyle = '#0a0a0a'; g.fillRect(24,26,16,16);
g.fillStyle = '#3a2a1a'; g.fillRect(26,28,12,12);
g.fillStyle = '#6a5030'; g.fillRect(0,0,64,3);
noise(g, 150, 0.18);
});
textures[5] = makeTexture(g => {
g.fillStyle = '#3a3a38'; g.fillRect(0,0,64,64);
g.fillStyle = '#4a4a48'; g.fillRect(2,2,60,60);
g.fillStyle = '#5a5a58'; g.fillRect(4,4,56,56);
g.fillStyle = '#2a2a28';
for (let y = 14; y < 64; y += 20) g.fillRect(0,y,64,2);
g.fillStyle = '#1a1a1a'; g.fillRect(8,20,48,24);
g.fillStyle = '#3a4a5a'; g.fillRect(10,22,44,20);
g.fillStyle = '#0a0a0a'; g.fillRect(30,22,4,20);
g.fillStyle = '#6a6a68'; g.fillRect(0,0,64,3);
noise(g, 200, 0.2);
});
textures[6] = makeTexture(g => {
g.fillStyle = '#4a3a20'; g.fillRect(0,0,64,64);
for (let row = 0; row < 4; row++){
const off = (row % 2) ? 8 : 0;
for (let i = -1; i < 5; i++){
const x = i*16 + off, y = row*16, v = 0.75 + Math.random()*0.4;
g.fillStyle = 'rgb(' + (150*v|0) + ',' + (125*v|0) + ',' + (80*v|0) + ')';
g.beginPath(); g.ellipse(x+8, y+8, 8, 7, 0, 0, Math.PI*2); g.fill();
g.fillStyle = 'rgba(0,0,0,.15)';
g.beginPath(); g.ellipse(x+8, y+10, 8, 3, 0, 0, Math.PI*2); g.fill();
}
}
noise(g, 200, 0.25);
});
const WALL_H = { 1:4.0, 2:2.6, 3:2.2, 4:2.2, 5:3.2, 6:0.85 };
/* ============================================================
5. 玩家精灵(联机时绘制其他玩家)
============================================================ */
function makeSprite(rows, palette){
const w = rows[0].length, h = rows.length;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const g = c.getContext('2d');
for (let y = 0; y < h; y++)
for (let x = 0; x < w; x++){
const col = palette[rows[y][x]];
if (!col) continue;
g.fillStyle = col; g.fillRect(x, y, 1, 1);
}
return c;
}
const ART_STAND = [
"......HHHH......",".....HHHHHH.....","....HHHHHHHH....",
"....HSSSSSSH....","....HSSSSSSH....",".....SSSSSS.....",
"......SSSS......","...UUUUUUUUUU...","..UUUUUUUUUUUU..",
".SUUUUUUUUUUUUS.",".SUUUUUUUUUUUUS.",".SUUUUUUUUUUUUS.",
"..UUUUUUUUUUUU..","..UUUUU..UUUUU..","..UUUU....UUUU..",
"..UUUU....UUUU..","..UUUU....UUUU..","..KKKK....KKKK..",
".KKKKK....KKKKK.",".KKKKK....KKKKK."
];
const ART_CROUCH = [
"................","......HHHH......",".....HHHHHH.....",
"....HHHHHHHH....","....HSSSSSSH....",".....SSSSSS.....",
"......SSSS......","...UUUUUUUUUU...","..UUUUUUUUUUUU..",
".SUUUUUUUUUUUUS.","..UUUUUUUUUUUU..","..UUUUU..UUUUU..",
"..UUUU....UUUU..","..KKKK....KKKK..",".KKKKK....KKKKK."
];
const ART_PRONE = [
"................","................","................",
"....HHHH........","...HHHHHH.......","..HHSSSSSSHH....",
"..SSSSSSSSSS....","...UUUUUUUUUU...","UUUUUUUUUUUUUUUU",
"UUUUUUUUUUUUUUUU",".UUUUUUUUUUUUUU.","..KKKK....KKKK..",
"..KKKKK..KKKKK..","................","................"
];
const PAL_PLAYER = { 'H':'#3a5a3a','S':'#e6b98c','U':'#4a6a5a','K':'#1a1a1a' };
const PLAYER_SPRITES = {
stand: makeSprite(ART_STAND, PAL_PLAYER),
crouch: makeSprite(ART_CROUCH, PAL_PLAYER),
prone: makeSprite(ART_PRONE, PAL_PLAYER)
};
/* ============================================================
6. 地图生成
============================================================ */
let map = [];
function generateTestMap(){
const S = MAP_SIZE;
const m = Array.from({ length: S }, () => new Array(S).fill(0));
for (let i = 0; i < S; i++){
m[0][i] = 1; m[S-1][i] = 1; m[i][0] = 1; m[i][S-1] = 1;
}
function makeBuilding(x, y, w, h, t){
for (let i = 0; i < w; i++){ m[y][x+i] = t; m[y+h-1][x+i] = t; }
for (let j = 0; j < h; j++){ m[y+j][x] = t; m[y+j][x+w-1] = t; }
m[y+h-1][x + Math.floor(w/2)] = 0;
}
makeBuilding(3, 3, 8, 7, 2);
makeBuilding(S-11, 3, 8, 7, 4);
makeBuilding(3, S-10, 8, 7, 5);
makeBuilding(S-11, S-10, 8, 7, 2);
const bunkers = [
[S/2-8,3,3,1],[S/2+5,3,3,1],[S/2-8,S-4,3,1],[S/2+5,S-4,3,1],
[3,S/2-8,1,3],[S-4,S/2-8,1,3],[3,S/2+5,1,3],[S-4,S/2+5,1,3],
[S/2-6,S/2-2,3,1],[S/2+3,S/2-2,3,1],[S/2-6,S/2+1,3,1],[S/2+3,S/2+1,3,1],
[S/2-2,S/2-6,1,3],[S/2-2,S/2+3,1,3],[S/2+1,S/2-6,1,3],[S/2+1,S/2+3,1,3]
];
for (const [x, y, w, h] of bunkers){
for (let j = 0; j < h; j++)
for (let i = 0; i < w; i++){
const yy = y+j, xx = x+i;
if (yy > 0 && yy < S-1 && xx > 0 && xx < S-1 && m[yy][xx] === 0) m[yy][xx] = 6;
}
}
return m;
}
/* ============================================================
7. 游戏状态
============================================================ */
const game = { state: 'lobby' };
const player = {
x: 20.5, y: 20.5, z: 0,
dir: 0, pitch: 0,
vz: 0, onGround: true,
crouch: 'stand',
moveSpeed: 0
};
const zBuffer = new Float32Array(W);
const floorImage = ctx.createImageData(W, H);
let bobPhase = 0;
let gameTime = 0;
let pendingJump = 0, pendingCrouch = 0, pendingProne = 0;
const jumpDelayAmount = 0.12, crouchDelayAmount = 0.15, proneDelayAmount = 0.22;
/* ============================================================
8. 联机核心
============================================================ */
const NET = {
channel: null,
supported: (typeof BroadcastChannel !== 'undefined'),
mode: null, // null | 'room' | 'server'
playerId: '',
playerName: '',
// 房间
roomId: '',
roomInfo: null, // { id, name, hasPass, password, maxPlayers }
isRoomHost: false,
roomHostId: '',
roomLastBeat: 0,
pendingJoinRoom: null,
// 服务器
serverIp: '',
serverInfo: null,
pendingJoinServer: null,
serverPlayerData: null,
// 玩家表
players: new Map(),
lastPosBroadcast: 0,
lastServerBeat: 0
};
const activeRooms = new Map(); // roomId -> { id, name, hasPass, maxPlayers, hostId, playerCount, lastSeen }
const serverOnline = new Map(); // ip -> Map(playerId -> lastSeen)
let roomHostTimer = null;
let roomCheckTimer = null;
let netSyncTimer = null;
let srvSaveTimer = null;
/* ---------- 身份 ---------- */
function getPlayerId(){
let id = null;
try { id = localStorage.getItem('jjtw_playerid'); } catch(_){}
if (!id){
id = 'p_' + Math.random().toString(36).slice(2, 10) + Date.now().toString(36);
try { localStorage.setItem('jjtw_playerid', id); } catch(_){}
}
return id;
}
function getPlayerName(){
let n = null;
try { n = localStorage.getItem('jjtw_playername'); } catch(_){}
if (!n){
n = '玩家' + Math.floor(Math.random()*9000+1000);
try { localStorage.setItem('jjtw_playername', n); } catch(_){}
}
return n;
}
function setPlayerName(n){
n = (n || '').trim().slice(0, 12);
if (!n) return;
NET.playerName = n;
try { localStorage.setItem('jjtw_playername', n); } catch(_){}
}
/* ---------- 初始化 ---------- */
function initNet(){
if (!NET.supported) return false;
if (NET.channel) return true;
try{
NET.channel = new BroadcastChannel('jjtw_online_v1');
NET.channel.onmessage = handleNetMessage;
}catch(_){
NET.supported = false;
return false;
}
NET.playerId = getPlayerId();
NET.playerName = getPlayerName();
return true;
}
/* ---------- 服务器注册表 ---------- */
function getServers(){
try { return JSON.parse(localStorage.getItem('jjtw_servers') || '{}'); } catch(_){ return {}; }
}
function saveServers(s){
try { localStorage.setItem('jjtw_servers', JSON.stringify(s)); } catch(_){}
}
function saveServerPlayerData(ip, playerId, data){
const key = 'jjtw_srvdata_' + ip;
let all = {};
try { all = JSON.parse(localStorage.getItem(key) || '{}'); } catch(_){}
all[playerId] = Object.assign(all[playerId] || {}, data, { lastSeen: Date.now() });
try { localStorage.setItem(key, JSON.stringify(all)); } catch(_){}
}
function loadServerPlayerData(ip, playerId){
try {
const all = JSON.parse(localStorage.getItem('jjtw_srvdata_' + ip) || '{}');
return all[playerId] || null;
} catch(_){ return null; }
}
/* ---------- 广播 ---------- */
function postMsg(m){
if (!NET.channel) return;
try { NET.channel.postMessage(m); } catch(_){}
}
function broadcastRoomHb(){
if (!NET.isRoomHost || !NET.roomInfo || !NET.channel) return;
postMsg({
type: 'room_hb',
room: {
id: NET.roomInfo.id,
name: NET.roomInfo.name,
hasPass: NET.roomInfo.hasPass,
maxPlayers: NET.roomInfo.maxPlayers
},
hostId: NET.playerId,
playerCount: NET.players.size
});
}
function broadcastRoomPlayers(){
if (!NET.isRoomHost || !NET.roomInfo) return;
const list = [];
NET.players.forEach((pl, id) => list.push({ id, name: pl.name }));
postMsg({ type: 'room_players', roomId: NET.roomId, players: list });
}
function broadcastServerBeat(){
if (NET.mode !== 'server' || !NET.serverIp) return;
postMsg({
type: 'srv_hb',
ip: NET.serverIp,
id: NET.playerId,
name: NET.playerName,
data: {
x: player.x, y: player.y, z: player.z,
dir: player.dir, crouch: player.crouch
}
});
}
/* ---------- 消息处理 ---------- */
function handleNetMessage(e){
const m = e.data;
if (!m || !m.type) return;
switch (m.type){
case 'room_hb': onRoomHeartbeat(m); break;
case 'room_join_req': onRoomJoinReq(m); break;
case 'room_join_res': onRoomJoinRes(m); break;
case 'room_players': onRoomPlayers(m); break;
case 'room_closed': onRoomClosed(m); break;
case 'srv_hb': onServerHeartbeat(m); break;
case 'pos': onPlayerPos(m); break;
case 'leave': onPlayerLeave(m); break;
}
}
function onRoomHeartbeat(m){
if (!m.room || !m.room.id) return;
const r = m.room;
r.lastSeen = performance.now();
r.hostId = m.hostId;
r.playerCount = m.playerCount || 0;
activeRooms.set(r.id, r);
if (onlinePanel.classList.contains('show')) renderRoomList();
}
function onRoomJoinReq(m){
if (!NET.isRoomHost || m.roomId !== NET.roomId) return;
let ok = true, reason = '';
if (NET.roomInfo.hasPass && m.password !== NET.roomInfo.password){
ok = false; reason = '密码错误';
}
if (ok && NET.players.size >= NET.roomInfo.maxPlayers){
ok = false; reason = '房间已满';
}
if (ok && NET.players.has(m.fromId)){
ok = false; reason = '已在房间内';
}
postMsg({
type: 'room_join_res',
toId: m.fromId,
ok: ok,
reason: reason,
hostId: NET.playerId,
room: ok ? {
id: NET.roomInfo.id,
name: NET.roomInfo.name,
maxPlayers: NET.roomInfo.maxPlayers
} : null
});
if (ok){
NET.players.set(m.fromId, {
id: m.fromId, name: m.fromName || '玩家',
x: player.x, y: player.y, z: 0, dir: 0, crouch: 'stand',
lastSeen: performance.now()
});
broadcastRoomPlayers();
updateNetBar();
}
}
function onRoomJoinRes(m){
if (m.toId !== NET.playerId) return;
if (!NET.pendingJoinRoom) return;
const pending = NET.pendingJoinRoom;
if (!m.room || m.room.id !== pending.id) return;
NET.pendingJoinRoom = null;
if (!m.ok){
olMsg('加入失败:' + (m.reason || '未知原因'), true);
return;
}
NET.mode = 'room';
NET.roomId = m.room.id;
NET.isRoomHost = false;
NET.roomHostId = m.hostId;
NET.roomInfo = {
id: m.room.id,
name: m.room.name,
hasPass: false,
maxPlayers: m.room.maxPlayers
};
NET.roomLastBeat = performance.now();
NET.players.clear();
NET.players.set(NET.playerId, makeSelfEntry());
startRoomMemberCheck();
enterOnlineGame();
showMsg('已加入房间 #' + m.room.id, 2000);
}
function onRoomPlayers(m){
if (NET.mode !== 'room' || m.roomId !== NET.roomId) return;
for (const p of m.players){
if (p.id === NET.playerId) continue;
if (!NET.players.has(p.id)){
NET.players.set(p.id, {
id: p.id, name: p.name || '玩家',
x: 0, y: 0, z: 0, dir: 0, crouch: 'stand',
lastSeen: 0
});
} else {
NET.players.get(p.id).name = p.name || '玩家';
}
}
updateNetBar();
}
function onRoomClosed(m){
if (NET.mode === 'room' && m.roomId === NET.roomId && !NET.isRoomHost){
leaveOnline('房主已解散房间');
}
}
function onServerHeartbeat(m){
if (!m.ip) return;
let map = serverOnline.get(m.ip);
if (!map){ map = new Map(); serverOnline.set(m.ip, map); }
map.set(m.id, performance.now());
if (m.id !== NET.playerId && NET.mode === 'server' && NET.serverIp === m.ip){
let pl = NET.players.get(m.id);
if (!pl){
pl = { id: m.id, name: m.name || '玩家' };
NET.players.set(m.id, pl);
}
if (m.data){
pl.x = m.data.x; pl.y = m.data.y; pl.z = m.data.z;
pl.dir = m.data.dir; pl.crouch = m.data.crouch;
}
pl.name = m.name || '玩家';
pl.lastSeen = performance.now();
updateNetBar();
}
}
function onPlayerPos(m){
if (m.id === NET.playerId) return;
if (NET.mode === 'room'){
if (m.scope !== 'room' || m.roomId !== NET.roomId) return;
} else if (NET.mode === 'server'){
if (m.scope !== 'server' || m.serverIp !== NET.serverIp) return;
} else {
return;
}
let pl = NET.players.get(m.id);
if (!pl){
pl = { id: m.id, name: m.name || '玩家' };
NET.players.set(m.id, pl);
}
pl.name = m.name || pl.name;
pl.x = m.x; pl.y = m.y; pl.z = m.z;
pl.dir = m.dir; pl.crouch = m.crouch;
pl.lastSeen = performance.now();
}
function onPlayerLeave(m){
if (!m.id || m.id === NET.playerId) return;
if (NET.mode === 'room' && m.scope === 'room' && m.roomId === NET.roomId){
NET.players.delete(m.id);
if (NET.isRoomHost) broadcastRoomPlayers();
updateNetBar();
} else if (NET.mode === 'server' && m.scope === 'server' && m.serverIp === NET.serverIp){
NET.players.delete(m.id);
updateNetBar();
}
}
function makeSelfEntry(){
return {
id: NET.playerId,
name: NET.playerName,
x: player.x, y: player.y, z: player.z,
dir: player.dir, crouch: player.crouch,
lastSeen: performance.now()
};
}
/* ---------- 房间 ---------- */
function createRoom(opts){
if (!initNet()){ olMsg('当前环境不支持联机', true); return; }
const roomId = genRoomId();
NET.mode = 'room';
NET.roomId = roomId;
NET.isRoomHost = true;
NET.roomHostId = NET.playerId;
NET.roomInfo = {
id: roomId,
name: opts.name || ('房间 ' + roomId),
hasPass: !!opts.password,
password: opts.password || '',
maxPlayers: opts.maxPlayers || 10
};
NET.players.clear();
NET.players.set(NET.playerId, makeSelfEntry());
startRoomHostLoop();
enterOnlineGame();
showMsg('房间已创建 · 房间号 ' + roomId, 3000);
}
function startRoomHostLoop(){
stopRoomHostLoop();
broadcastRoomHb();
broadcastRoomPlayers();
roomHostTimer = setInterval(() => {
broadcastRoomHb();
broadcastRoomPlayers();
updateNetBar();
}, 1000);
}
function stopRoomHostLoop(){
if (roomHostTimer){ clearInterval(roomHostTimer); roomHostTimer = null; }
}
function startRoomMemberCheck(){
stopRoomMemberCheck();
roomCheckTimer = setInterval(() => {
if (NET.mode === 'room' && !NET.isRoomHost){
if (performance.now() - NET.roomLastBeat > 3500){
leaveOnline('房主已离开,房间已解散');
}
}
}, 1000);
}
function stopRoomMemberCheck(){
if (roomCheckTimer){ clearInterval(roomCheckTimer); roomCheckTimer = null; }
}
function joinRoom(roomId, password){
if (!initNet()){ olMsg('当前环境不支持联机', true); return; }
if (!roomId){ olMsg('请输入房间号', true); return; }
const room = activeRooms.get(roomId);
if (!room){ olMsg('房间不存在或已解散', true); return; }
if (room.playerCount >= room.maxPlayers){ olMsg('房间已满', true); return; }
if (room.hasPass && !password){ olMsg('该房间需要密码', true); return; }
NET.pendingJoinRoom = { id: roomId, password: password || '' };
postMsg({
type: 'room_join_req',
fromId: NET.playerId,
fromName: NET.playerName,
roomId: roomId,
password: password || ''
});
setTimeout(() => {
if (NET.pendingJoinRoom && NET.pendingJoinRoom.id === roomId){
NET.pendingJoinRoom = null;
olMsg('加入房间超时(房主未响应)', true);
}
}, 2500);
}
/* ---------- 服务器 ---------- */
function createServer(opts){
if (!initNet()){ olMsg('当前环境不支持联机', true); return; }
const ip = (opts.ip || '').trim() || genServerIp();
const servers = getServers();
if (servers[ip]){ olMsg('该 IP 已被占用', true); return; }
servers[ip] = {
ip: ip,
name: opts.name || ('服务器 ' + ip),
hasPass: !!opts.password,
passwordHash: opts.password ? hashPass(opts.password) : '',
maxPlayers: opts.maxPlayers || 10,
createdAt: Date.now()
};
saveServers(servers);
joinServer(ip, opts.password || '');
}
function joinServer(ip, password){
if (!initNet()){ olMsg('当前环境不支持联机', true); return; }
ip = (ip || '').trim();
if (!ip){ olMsg('请输入服务器 IP', true); return; }
const servers = getServers();
const s = servers[ip];
if (!s){ olMsg('服务器不存在,请检查 IP', true); return; }
if (s.hasPass && hashPass(password || '') !== s.passwordHash){
olMsg('服务器密码错误', true); return;
}
NET.pendingJoinServer = { ip: ip };
postMsg({
type: 'srv_join_req',
fromId: NET.playerId,
fromName: NET.playerName,
ip: ip
});
setTimeout(() => {
if (!NET.pendingJoinServer) return;
NET.pendingJoinServer = null;
NET.mode = 'server';
NET.serverIp = ip;
NET.serverInfo = s;
NET.players.clear();
NET.players.set(NET.playerId, makeSelfEntry());
NET.serverPlayerData = loadServerPlayerData(ip, NET.playerId);
startServerSync();
enterOnlineGame();
if (NET.serverPlayerData){
showMsg('已加入服务器 · 存档已恢复', 2400);
} else {
showMsg('已加入服务器 ' + (s.name || ip), 2200);
}
}, 500);
}
/* ---------- 同步循环 ---------- */
function startServerSync(){
if (srvSaveTimer) return;
srvSaveTimer = setInterval(() => {
if (NET.mode === 'server' && NET.serverIp && game.state === 'ground'){
saveServerPlayerData(NET.serverIp, NET.playerId, {
x: player.x, y: player.y, z: player.z,
dir: player.dir, crouch: player.crouch,
playTime: gameTime
});
}
}, 5000);
}
function stopServerSync(){
if (srvSaveTimer){ clearInterval(srvSaveTimer); srvSaveTimer = null; }
}
function startNetSync(){
stopNetSync();
netSyncTimer = setInterval(() => {
if (!NET.mode) return;
if (game.state !== 'ground' && game.state !== 'lobby'){
// 只在 ground 状态同步
}
if (game.state !== 'ground') return;
const now = performance.now();
postMsg({
type: 'pos',
scope: NET.mode,
roomId: NET.roomId,
serverIp: NET.serverIp,
id: NET.playerId,
name: NET.playerName,
x: player.x, y: player.y, z: player.z,
dir: player.dir,
crouch: player.crouch
});
if (NET.mode === 'server' && now - NET.lastServerBeat > 900){
NET.lastServerBeat = now;
broadcastServerBeat();
}
}, 100);
}
function stopNetSync(){
if (netSyncTimer){ clearInterval(netSyncTimer); netSyncTimer = null; }
}
/* ---------- 玩家超时清理 ---------- */
setInterval(() => {
const now = performance.now();
let changed = false;
NET.players.forEach((pl, id) => {
if (id === NET.playerId) return;
if (pl.lastSeen && now - pl.lastSeen > 5000){
NET.players.delete(id);
changed = true;
}
});
// 房间列表超时
activeRooms.forEach((r, id) => {
if (now - r.lastSeen > 3500) activeRooms.delete(id);
});
// 服务器在线超时
serverOnline.forEach(map => {
map.forEach((t, id) => { if (now - t > 3500) map.delete(id); });
});
if (changed) updateNetBar();
if (onlinePanel.classList.contains('show')){
renderRoomList();
renderServerList();
}
}, 1000);
/* ---------- 进入 / 离开 ---------- */
function enterOnlineGame(){
onlinePanel.classList.remove('show');
lobbyEl.classList.add('hide');
$('game').classList.add('show');
goFullscreen();
resize();
startGame();
// 服务器:恢复存档
if (NET.mode === 'server' && NET.serverPlayerData){
restorePlayerFromData(NET.serverPlayerData);
}
startNetSync();
if (NET.mode === 'server') startServerSync();
updateNetBar();
$('netLeaveBtn').classList.add('show');
$('netGrp').style.display = '';
}
function restorePlayerFromData(d){
if (!d) return;
if (typeof d.x === 'number') player.x = d.x;
if (typeof d.y === 'number') player.y = d.y;
if (typeof d.dir === 'number') player.dir = d.dir;
if (typeof d.crouch === 'string') player.crouch = d.crouch;
player.z = getFloorHeightAt(player.x, player.y);
player.vz = 0; player.onGround = true;
updatePoseUI();
updateHUD();
}
function leaveOnline(reason){
if (NET.mode === 'server' && NET.serverIp){
saveServerPlayerData(NET.serverIp, NET.playerId, {
x: player.x, y: player.y, z: player.z,
dir: player.dir, crouch: player.crouch,
playTime: gameTime
});
}
if (NET.mode && NET.channel){
postMsg({
type: 'leave',
id: NET.playerId,
scope: NET.mode,
roomId: NET.roomId,
serverIp: NET.serverIp
});
}
if (NET.isRoomHost && NET.channel){
postMsg({ type: 'room_closed', roomId: NET.roomId });
}
stopRoomHostLoop();
stopRoomMemberCheck();
stopNetSync();
stopServerSync();
NET.mode = null;
NET.roomId = '';
NET.roomInfo = null;
NET.isRoomHost = false;
NET.roomHostId = '';
NET.serverIp = '';
NET.serverInfo = null;
NET.serverPlayerData = null;
NET.players.clear();
NET.pendingJoinRoom = null;
NET.pendingJoinServer = null;
// 回大厅
$('game').classList.remove('show');
lobbyEl.classList.remove('hide');
document.body.classList.remove('playing');
game.state = 'lobby';
$('netLeaveBtn').classList.remove('show');
$('netGrp').style.display = 'none';
if (document.exitPointerLock) document.exitPointerLock();
if (reason) showMsg(reason, 2200);
openOnlinePanel();
}
function updateNetBar(){
const grp = $('netGrp'), info = $('netInfo');
if (!grp || !info) return;
if (!NET.mode){ grp.style.display = 'none'; return; }
grp.style.display = '';
const max = NET.mode === 'room'
? (NET.roomInfo ? NET.roomInfo.maxPlayers : '?')
: (NET.serverInfo ? NET.serverInfo.maxPlayers : '?');
const count = NET.players.size;
if (NET.mode === 'room'){
info.textContent = '#' + NET.roomId + ' ' + count + '/' + max;
} else {
info.textContent = NET.serverIp + ' ' + count + '/' + max;
}
}
/* ============================================================
9. 联机 UI
============================================================ */
let currentTab = 'room';
function olMsg(text, isErr){
onlineMsg.textContent = text || '';
onlineMsg.classList.toggle('err', !!isErr);
clearTimeout(olMsg._t);
olMsg._t = setTimeout(() => { onlineMsg.textContent = ''; onlineMsg.classList.remove('err'); }, 3200);
}
function openOnlinePanel(){
if (!initNet()){
olMsg('当前浏览器不支持联机(需 BroadcastChannel)', true);
}
onlinePanel.classList.add('show');
$('playerNameInput').value = NET.playerName || getPlayerName();
renderRoomList();
renderServerList();
}
$('onlineBtn').addEventListener('click', openOnlinePanel);
$('onlineBack').addEventListener('click', () => onlinePanel.classList.remove('show'));
$('playerNameInput').addEventListener('change', e => {
setPlayerName(e.target.value);
olMsg('名字已更新:' + NET.playerName);
});
document.querySelectorAll('.ol-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.ol-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentTab = tab.dataset.tab;
$('olBodyRoom').style.display = currentTab === 'room' ? 'flex' : 'none';
$('olBodyServer').style.display = currentTab === 'server' ? 'flex' : 'none';
if (currentTab === 'room') renderRoomList();
else renderServerList();
});
});
/* ---- 房间列表 ---- */
function renderRoomList(){
const el = $('roomList');
if (!el) return;
const now = performance.now();
const rooms = [];
activeRooms.forEach(r => { if (now - r.lastSeen < 3500) rooms.push(r); });
rooms.sort((a, b) => b.playerCount - a.playerCount);
if (!rooms.length){
el.innerHTML = '<div class="ol-empty">— 暂无活跃房间 —</div>';
return;
}
el.innerHTML = rooms.map(r => `
<div class="ol-item">
<div class="ol-item-main">
<div class="ol-item-name">${escapeHtml(r.name || ('房间 ' + r.id))}${r.hasPass ? ' 🔒' : ''}</div>
<div class="ol-item-sub">房间号 ${r.id} · ${r.playerCount}/${r.maxPlayers} 人</div>
</div>
<button class="ol-mini" data-roomid="${r.id}" data-haspass="${r.hasPass ? 1 : 0}">加入</button>
</div>
`).join('');
el.querySelectorAll('.ol-mini').forEach(btn => {
btn.addEventListener('click', () => {
const id = btn.dataset.roomid;
$('roomJoinId').value = id;
const needPass = btn.dataset.haspass === '1';
$('roomJoinPass').focus();
if (needPass) olMsg('该房间需要密码,请输入后点「加入房间」');
else olMsg('已填入房间号,点击「加入房间」');
});
});
}
/* ---- 服务器列表 ---- */
function renderServerList(){
const el = $('serverList');
if (!el) return;
const servers = getServers();
const list = Object.values(servers);
if (!list.length){
el.innerHTML = '<div class="ol-empty">— 暂无服务器 —</div>';
return;
}
list.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
el.innerHTML = list.map(s => {
const online = getServerOnlineCount(s.ip);
return `
<div class="ol-item">
<div class="ol-item-main">
<div class="ol-item-name">${escapeHtml(s.name || s.ip)}${s.hasPass ? ' 🔒' : ''}</div>
<div class="ol-item-sub">${escapeHtml(s.ip)} · 在线 ${online}/${s.maxPlayers}</div>
</div>
<button class="ol-mini" data-ip="${escapeHtml(s.ip)}" data-haspass="${s.hasPass ? 1 : 0}">加入</button>
</div>`;
}).join('');
el.querySelectorAll('.ol-mini').forEach(btn => {
btn.addEventListener('click', () => {
$('serverJoinIp').value = btn.dataset.ip;
if (btn.dataset.haspass === '1') olMsg('该服务器需要密码,请输入后点「加入服务器」');
else olMsg('已填入 IP,点击「加入服务器」');
});
});
}
function getServerOnlineCount(ip){
const map = serverOnline.get(ip);
if (!map) return 0;
const now = performance.now();
let n = 0;
map.forEach(t => { if (now - t < 3500) n++; });
return n;
}
/* ---- 房间操作 ---- */
$('roomCreateMax').addEventListener('input', e => {
$('roomCreateMaxVal').textContent = e.target.value;
});
$('roomCreateBtn').addEventListener('click', () => {
createRoom({
name: $('roomCreateName').value.trim(),
password: $('roomCreatePass').value,
maxPlayers: parseInt($('roomCreateMax').value) || 10
});
});
$('roomJoinBtn').addEventListener('click', () => {
joinRoom($('roomJoinId').value.trim(), $('roomJoinPass').value);
});
/* ---- 服务器操作 ---- */
$('serverCreateIp').value = genServerIp();
$('serverCreateBtn').addEventListener('click', () => {
createServer({
name: $('serverCreateName').value.trim(),
ip: $('serverCreateIp').value.trim(),
password: $('serverCreatePass').value,
maxPlayers: parseInt($('serverCreateMax').value) || 10
});
});
$('serverJoinBtn').addEventListener('click', () => {
joinServer($('serverJoinIp').value.trim(), $('serverJoinPass').value);
});
/* ---- 离开联机按钮 ---- */
$('netLeaveBtn').addEventListener('click', () => {
if (confirm('确定离开联机并返回大厅吗?')) leaveOnline();
});
/* ---- 页面关闭 ---- */
window.addEventListener('beforeunload', () => {
if (NET.mode === 'server' && NET.serverIp){
saveServerPlayerData(NET.serverIp, NET.playerId, {
x: player.x, y: player.y, z: player.z,
dir: player.dir, crouch: player.crouch,
playTime: gameTime
});
}
if (NET.isRoomHost && NET.channel){
postMsg({ type: 'room_closed', roomId: NET.roomId });
}
if (NET.mode && NET.channel){
postMsg({
type: 'leave', id: NET.playerId, scope: NET.mode,
roomId: NET.roomId, serverIp: NET.serverIp
});
}
});
/* ============================================================
10. 世界查询(碰撞 / 台阶 / 落脚高度)
============================================================ */
function cellAt(x, y){
const mx = Math.floor(x), my = Math.floor(y);
if (mx < 0 || my < 0 || mx >= MAP_SIZE || my >= MAP_SIZE) return 1;
return map[my][mx];
}
function isWall(x, y){ return cellAt(x, y) !== 0; }
function canMoveAt(x, y, r, z){
const corners = [[-r,-r],[r,-r],[-r,r],[r,r]];
for (const [dx, dy] of corners){
const t = cellAt(x + dx, y + dy);
if (t > 0){
const h = WALL_H[t] || 2.5;
if (h > z + 0.35) return false;
}
}
return true;
}
function getFloorHeightAt(x, y){
const t = cellAt(x, y);
if (t === 0) return 0;
return WALL_H[t] || 2.5;
}
function findSafeSpot(x, y){
if (canMoveAt(x, y, PLAYER_R, 0)) return [x, y];
for (let r = 0.5; r < 8; r += 0.5)
for (let a = 0; a < Math.PI*2; a += Math.PI/8){
const nx = x + Math.cos(a)*r, ny = y + Math.sin(a)*r;
if (canMoveAt(nx, ny, PLAYER_R, 0)) return [nx, ny];
}
return [x, y];
}
function getEyeHeight(){
const c = player.crouch;
if (c === 'prone') return EYE_PRONE;
if (c === 'crouch') return EYE_CROUCH;
return EYE_STAND;
}
/* ============================================================
11. 大厅 / 开始
============================================================ */
$('startBtn').addEventListener('click', () => {
leaveOnlineSilently();
lobbyEl.classList.add('hide');
$('game').classList.add('show');
goFullscreen();
resize();
startGame();
$('netLeaveBtn').classList.remove('show');
$('netGrp').style.display = 'none';
});
function leaveOnlineSilently(){
stopRoomHostLoop();
stopRoomMemberCheck();
stopNetSync();
stopServerSync();
NET.mode = null;
NET.roomId = '';
NET.roomInfo = null;
NET.isRoomHost = false;
NET.serverIp = '';
NET.serverInfo = null;
NET.serverPlayerData = null;
NET.players.clear();
}
function goFullscreen(){
if (!IS_TOUCH) return;
const el = document.documentElement;
try{
if (el.requestFullscreen) el.requestFullscreen().catch(() => {});
else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen();
}catch(_){}
}
function startGame(){
map = generateTestMap();
const [sx, sy] = findSafeSpot(MAP_SIZE/2 + 0.5, MAP_SIZE/2 + 0.5);
player.x = sx; player.y = sy;
player.z = 0; player.vz = 0;
player.dir = 0; player.pitch = 0;
player.onGround = true;
player.crouch = 'stand';
player.moveSpeed = 0;
pendingJump = pendingCrouch = pendingProne = 0;
touchMove.active = false; touchMove.x = 0; touchMove.y = 0;
if (moveStickEl) moveStickEl.querySelector('.knob').style.transform = '';
gameTime = 0; bobPhase = 0;
game.state = 'ground';
document.body.classList.add('playing');
updatePoseUI();
updateHUD();
if (NET.mode){
showMsg('联机中 · WASD 移动 · 空格 跳 · C 蹲 · Z 趴', 3000);
} else {
showMsg('WASD 移动 · 空格 跳跃 · C 蹲 · Z 趴', 3200);
}
}
/* ============================================================
12. 跳跃 / 蹲伏 / 趴下
============================================================ */
function requestJump(){
if (player.crouch === 'prone' || pendingJump > 0) return;
pendingJump = jumpDelayAmount;
}
function requestCrouch(){
if (pendingCrouch > 0 || pendingProne > 0) return;
pendingCrouch = crouchDelayAmount;
}
function requestProne(){
if (pendingProne > 0 || pendingCrouch > 0) return;
pendingProne = proneDelayAmount;
}
function updatePoseUI(){
const c = player.crouch;
const cb = $('crouchBtn'), pb = $('proneBtn');
if (cb) cb.classList.toggle('on', c === 'crouch');
if (pb) pb.classList.toggle('on', c === 'prone');
}
/* ============================================================
13. HUD
============================================================ */
const hHeight = $('hHeight'), hPose = $('hPose'), hSpeed = $('hSpeed');
const POSE_NAMES = { stand:'站立', crouch:'蹲伏', prone:'趴下' };
function updateHUD(){
hHeight.textContent = player.z.toFixed(2);
hPose.textContent = POSE_NAMES[player.crouch] || '站立';
hSpeed.textContent = player.moveSpeed.toFixed(1);
updateNetBar();
}
/* ============================================================
14. 输入:视角
============================================================ */
let lookPid = null, lookLastX = 0, lookLastY = 0;
function applyLook(dx, dy){
player.dir += dx * settings.sensHip;
player.pitch -= dy * settings.sensY;
const pr = settings.pitchRange;
player.pitch = Math.max(-pr, Math.min(pr, player.pitch));
}
function startLook(e){
lookPid = e.pointerId;
lookLastX = e.clientX; lookLastY = e.clientY;
window.addEventListener('pointermove', onLookMove, { passive: false });
window.addEventListener('pointerup', onLookUp);
window.addEventListener('pointercancel', onLookUp);
}
function onLookMove(e){
if (e.pointerId !== lookPid) return;
const dx = e.clientX - lookLastX, dy = e.clientY - lookLastY;
lookLastX = e.clientX; lookLastY = e.clientY;
applyLook(dx, dy);
e.preventDefault();
}
function onLookUp(e){
if (e.pointerId !== lookPid) return;
lookPid = null;
window.removeEventListener('pointermove', onLookMove);
window.removeEventListener('pointerup', onLookUp);
window.removeEventListener('pointercancel', onLookUp);
}
stageEl.addEventListener('pointerdown', e => {
if (game.state !== 'ground') return;
if (lookPid !== null) return;
const t = e.target;
const keyEl = t.closest ? t.closest('.actionBtn, .stick') : null;
if (keyEl) return;
if (t.closest && t.closest('button, input, #setPanel, #setBtn, #onlinePanel, #netLeaveBtn, .ol-btn, .ol-input')) return;
startLook(e);
if (e.cancelable) e.preventDefault();
}, { passive: false });
canvas.addEventListener('mousedown', e => {
if (IS_TOUCH) return;
if (game.state !== 'ground') return;
if (document.pointerLockElement !== canvas && canvas.requestPointerLock) canvas.requestPointerLock();
});
window.addEventListener('contextmenu', e => e.preventDefault());
document.addEventListener('mousemove', e => {
if (IS_TOUCH) return;
if (document.pointerLockElement === canvas) applyLook(e.movementX, e.movementY);
});
/* ============================================================
15. 输入:键盘
============================================================ */
window.addEventListener('keydown', e => {
keys[e.code] = true;
if (['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','Space'].includes(e.code)) e.preventDefault();
if (game.state !== 'ground') return;
if (e.code === 'Space') requestJump();
if (e.code === 'KeyC') requestCrouch();
if (e.code === 'KeyZ') requestProne();
});
window.addEventListener('keyup', e => { keys[e.code] = false; });
/* ============================================================
16. 输入:触屏摇杆 / 按钮
============================================================ */
const touchMove = { active: false, x: 0, y: 0 };
function setupStick(el, onMove, onEnd){
const knob = el.querySelector('.knob');
let pid = null, cx = 0, cy = 0, R = 1;
function apply(e){
let dx = e.clientX - cx, dy = e.clientY - cy;
const d = Math.hypot(dx, dy);
if (d > R){ dx = dx/d*R; dy = dy/d*R; }
knob.style.transform = 'translate(' + dx.toFixed(1) + 'px,' + dy.toFixed(1) + 'px)';
onMove(dx/R, dy/R);
}
el.addEventListener('pointerdown', e => {
if (pid !== null) return;
pid = e.pointerId;
try { el.setPointerCapture(pid); } catch(_){}
const r = el.getBoundingClientRect();
cx = r.left + r.width/2; cy = r.top + r.height/2;
R = r.width * 0.32;
apply(e);
if (e.cancelable) e.preventDefault();
}, { passive: false });
el.addEventListener('pointermove', e => {
if (e.pointerId !== pid) return;
apply(e);
if (e.cancelable) e.preventDefault();
}, { passive: false });
function release(e){
if (e.pointerId !== pid) return;
pid = null; knob.style.transform = ''; onEnd();
}
el.addEventListener('pointerup', release);
el.addEventListener('pointercancel', release);
el.addEventListener('lostpointercapture', release);
}
setupStick(moveStickEl,
(x, y) => { touchMove.active = true; touchMove.x = x; touchMove.y = y; },
() => { touchMove.active = false; touchMove.x = 0; touchMove.y = 0; }
);
function bindBtn(id, onDown){
const el = $(id);
if (!el) return;
let pid = null;
el.addEventListener('pointerdown', e => {
if (pid !== null) return;
pid = e.pointerId;
el.classList.add('on');
e.preventDefault(); e.stopPropagation();
onDown();
}, { passive: false });
const up = e => {
if (e.pointerId !== pid) return;
pid = null;
el.classList.remove('on');
};
window.addEventListener('pointerup', up);
window.addEventListener('pointercancel', up);
}
bindBtn('jumpBtn', () => { if (game.state === 'ground') requestJump(); });
bindBtn('crouchBtn', () => { if (game.state === 'ground') requestCrouch(); });
bindBtn('proneBtn', () => { if (game.state === 'ground') requestProne(); });
/* ============================================================
17. 触屏按键布局
============================================================ */
const KEY_DEFS = {
moveStick: { base:94, isStick:true, def:{ x:0.105, y:0.72 } },
jumpBtn: { base:54, def:{ x:0.680, y:0.80 } },
crouchBtn: { base:54, def:{ x:0.800, y:0.80 } },
proneBtn: { base:54, def:{ x:0.920, y:0.80 } }
};
function getUiScale(){
const vw = window.innerWidth, vh = window.innerHeight;
return Math.max(0.68, Math.min(1.25, Math.min(vw / 900, vh / 450)));
}
function layoutKeys(){
const vw = window.innerWidth, vh = window.innerHeight, sc = getUiScale();
for (const id in KEY_DEFS){
const el = $(id);
if (!el) continue;
const cfg = KEY_DEFS[id];
const size = cfg.base * sc;
el.style.left = Math.round(cfg.def.x * vw - size / 2) + 'px';
el.style.top = Math.round(cfg.def.y * vh - size / 2) + 'px';
el.style.width = Math.round(size) + 'px';
el.style.height = Math.round(size) + 'px';
el.style.right = 'auto'; el.style.bottom = 'auto';
if (cfg.isStick){
const knob = el.querySelector('.knob');
if (knob){
const ks = Math.round(size * 0.40);
knob.style.width = ks + 'px'; knob.style.height = ks + 'px';
knob.style.margin = (-ks/2) + 'px 0 0 ' + (-ks/2) + 'px';
}
}
}
}
/* ============================================================
18. 窗口尺寸
============================================================ */
function resize(){
const vp = getViewport();
const s = Math.min(vp.w / W, vp.h / H);
canvas.style.width = Math.round(W * s) + 'px';
canvas.style.height = Math.round(H * s) + 'px';
const rh = $('rotateHint');
if (rh) rh.style.display = (vp.h > vp.w) ? 'flex' : 'none';
layoutKeys();
}
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', () => setTimeout(resize, 120));
if (window.visualViewport) window.visualViewport.addEventListener('resize', resize);
/* ============================================================
19. 更新:移动 / 重力 / 姿态
============================================================ */
function update(dt){
gameTime += dt;
if (game.state !== 'ground') return;
const p = player;
/* ---- 输入延迟处理 ---- */
if (pendingJump > 0){
pendingJump -= dt;
if (pendingJump <= 0){
pendingJump = 0;
if (p.onGround && p.crouch !== 'prone'){
p.vz = 7;
p.onGround = false;
if (p.crouch === 'crouch'){ p.crouch = 'stand'; updatePoseUI(); }
}
}
}
if (pendingCrouch > 0){
pendingCrouch -= dt;
if (pendingCrouch <= 0){
pendingCrouch = 0;
p.crouch = (p.crouch === 'crouch') ? 'stand' : 'crouch';
updatePoseUI();
}
}
if (pendingProne > 0){
pendingProne -= dt;
if (pendingProne <= 0){
pendingProne = 0;
p.crouch = (p.crouch === 'prone') ? 'stand' : 'prone';
updatePoseUI();
}
}
/* ---- 水平移动 ---- */
const dirX = Math.cos(p.dir), dirY = Math.sin(p.dir);
let mvX = 0, mvY = 0;
let speedMod = 1.0;
if (p.crouch === 'crouch') speedMod = 0.55;
else if (p.crouch === 'prone') speedMod = 0.28;
const sp = MOVE_SPEED * speedMod * dt;
if (keys['KeyW'] || keys['ArrowUp']) { mvX += dirX*sp; mvY += dirY*sp; }
if (keys['KeyS'] || keys['ArrowDown']) { mvX -= dirX*sp; mvY -= dirY*sp; }
if (keys['KeyA']) { mvX += dirY*sp*0.8; mvY -= dirX*sp*0.8; }
if (keys['KeyD']) { mvX -= dirY*sp*0.8; mvY += dirX*sp*0.8; }
if (touchMove.active){
const f = -touchMove.y, s = touchMove.x;
mvX += dirX*f*sp + (-dirY)*s*sp*0.8;
mvY += dirY*f*sp + ( dirX)*s*sp*0.8;
}
const newX = p.x + mvX, newY = p.y + mvY;
if (canMoveAt(newX, newY, PLAYER_R, p.z)){ p.x = newX; p.y = newY; }
else {
if (canMoveAt(newX, p.y, PLAYER_R, p.z)) p.x = newX;
if (canMoveAt(p.x, newY, PLAYER_R, p.z)) p.y = newY;
}
p.moveSpeed = Math.hypot(mvX, mvY) / (dt || 1);
if (mvX || mvY) bobPhase += dt * 11;
/* ---- 重力 & 落脚 ---- */
const floorH = getFloorHeightAt(p.x, p.y);
if (!p.onGround || p.z > floorH + 0.05){
if (p.onGround){ p.onGround = false; p.vz = 0; }
p.vz -= GRAVITY * dt;
if (p.vz < -MAX_FALL) p.vz = -MAX_FALL;
p.z += p.vz * dt;
const fh = getFloorHeightAt(p.x, p.y);
if (p.z <= fh && p.vz <= 0){
const impact = -p.vz;
p.z = fh; p.vz = 0; p.onGround = true;
if (impact > 12){
showMsg('重落地 · 冲击 ' + impact.toFixed(1), 1200);
} else if (fh > 0.1){
showMsg('登上 ' + fh.toFixed(2) + 'm 平台', 1100);
}
}
} else {
if (p.z < floorH - 0.05){ p.z = floorH; p.vz = 0; p.onGround = true; }
else if (p.z > floorH + 0.1) p.onGround = false;
}
if (p.z < 0){ p.z = 0; p.vz = 0; p.onGround = true; }
if (!canMoveAt(p.x, p.y, PLAYER_R, p.z)){
const [sx, sy] = findSafeSpot(p.x, p.y);
p.x = sx; p.y = sy; p.z = getFloorHeightAt(sx, sy);
}
/* 更新自己在联机玩家表中的信息 */
if (NET.mode){
let me = NET.players.get(NET.playerId);
if (!me){ me = makeSelfEntry(); NET.players.set(NET.playerId, me); }
me.x = p.x; me.y = p.y; me.z = p.z;
me.dir = p.dir; me.crouch = p.crouch;
me.name = NET.playerName;
me.lastSeen = performance.now();
}
updateHUD();
}
/* ============================================================
20. 渲染:伪3D 光线投射
============================================================ */
function render(){
if (!map.length) return;
const p = player;
const dirX = Math.cos(p.dir), dirY = Math.sin(p.dir);
const planeX = -Math.sin(p.dir) * FOV_PLANE;
const planeY = Math.cos(p.dir) * FOV_PLANE;
const playerMapX = Math.floor(p.x), playerMapY = Math.floor(p.y);
const playerTile = (playerMapX >= 0 && playerMapX < MAP_SIZE &&
playerMapY >= 0 && playerMapY < MAP_SIZE) ? map[playerMapY][playerMapX] : 0;
const onRoof = p.z > 0.1 && playerTile > 0;
const camZ = p.z + getEyeHeight();
const horizon = H / 2 + p.pitch;
/* ---- 天空 ---- */
const skyGrad = ctx.createLinearGradient(0, 0, 0, H);
if (p.z > 12){
skyGrad.addColorStop(0, '#0a1a3a');
skyGrad.addColorStop(0.5, '#3a6a9a');
skyGrad.addColorStop(1, '#8ab0d0');
} else {
skyGrad.addColorStop(0, '#2a4a6a');
skyGrad.addColorStop(0.55, '#6a9ab8');
skyGrad.addColorStop(1, '#a8c0c8');
}
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, W, H);
/* ---- 地面 ---- */
const groundTop = Math.max(0, Math.floor(horizon));
ctx.fillStyle = onRoof ? '#3a3a3a' : '#2a3a20';
ctx.fillRect(0, groundTop, W, H - groundTop);
if (groundTop < H) castFloor(horizon, dirX, dirY, planeX, planeY, camZ);
/* ---- 墙体 DDA ---- */
for (let x = 0; x < W; x++){
const cameraX = 2 * x / W - 1;
const rayDirX = dirX + planeX * cameraX;
const rayDirY = dirY + planeY * cameraX;
let mapX = playerMapX, mapY = playerMapY;
const deltaDistX = Math.abs(1 / (rayDirX || 1e-30));
const deltaDistY = Math.abs(1 / (rayDirY || 1e-30));
let stepX, stepY, sideDistX, sideDistY;
if (rayDirX < 0){ stepX = -1; sideDistX = (p.x - mapX) * deltaDistX; }
else { stepX = 1; sideDistX = (mapX + 1 - p.x) * deltaDistX; }
if (rayDirY < 0){ stepY = -1; sideDistY = (p.y - mapY) * deltaDistY; }
else { stepY = 1; sideDistY = (mapY + 1 - p.y) * deltaDistY; }
let side = 0, tile = 1, hit = false, guard = 0;
while (!hit && guard++ < 500){
if (sideDistX < sideDistY){ sideDistX += deltaDistX; mapX += stepX; side = 0; }
else { sideDistY += deltaDistY; mapY += stepY; side = 1; }
if (mapX < 0 || mapY < 0 || mapX >= MAP_SIZE || mapY >= MAP_SIZE){ tile = 1; break; }
tile = map[mapY][mapX];
if (tile > 0){
if (onRoof && mapX === playerMapX && mapY === playerMapY) continue;
hit = true;
}
}
let perp = side === 0 ? (sideDistX - deltaDistX) : (sideDistY - deltaDistY);
if (perp < 0.0001) perp = 0.0001;
zBuffer[x] = perp;
const wh = WALL_H[tile] || 2.5;
const lineH = (H / perp) * wh;
const wallBottom = horizon + (H / perp) * camZ;
const wallTop = wallBottom - lineH;
const y0 = Math.max(0, Math.floor(wallTop));
const y1 = Math.min(H, Math.ceil(wallBottom));
if (y1 <= y0) continue;
let wallX = side === 0 ? p.y + perp * rayDirY : p.x + perp * rayDirX;
wallX -= Math.floor(wallX);
let texX = Math.floor(wallX * 64);
if (side === 0 && rayDirX > 0) texX = 63 - texX;
if (side === 1 && rayDirY < 0) texX = 63 - texX;
texX = Math.max(0, Math.min(63, texX));
const tex = textures[tile] || textures[1];
const v0 = (y0 - wallTop) / lineH * 64;
const vh = (y1 - y0) / lineH * 64;
let srcY = v0, srcH = vh, dstY = y0, dstH = y1 - y0;
if (srcY < 0){ const ratio = -srcY / vh; dstY += dstH * ratio; dstH -= dstH * ratio; srcH -= -srcY; srcY = 0; }
if (srcY + srcH > 64){ const cut = srcY + srcH - 64; const ratio = cut / vh; dstH -= dstH * ratio; srcH -= cut; }
const srcYi = Math.max(0, Math.min(63, Math.floor(srcY)));
const srcHi = Math.max(1, Math.min(64 - srcYi, Math.ceil(srcH)));
const dstYi = Math.max(0, Math.min(H - 1, Math.floor(dstY)));
const dstHi = Math.max(1, Math.min(H - dstYi, Math.ceil(dstH)));
if (srcHi > 0 && dstHi > 0) ctx.drawImage(tex, texX, srcYi, 1, srcHi, x, dstYi, 1, dstHi);
let shade = Math.min(0.55, perp / 30);
if (side === 1) shade += 0.12;
shade = Math.min(0.65, shade);
if (shade > 0.02){
ctx.fillStyle = 'rgba(0,0,0,' + shade.toFixed(3) + ')';
ctx.fillRect(x, y0, 1, y1 - y0);
}
}
/* ---- 联机:绘制其他玩家 ---- */
drawNetPlayers(horizon, dirX, dirY, planeX, planeY, camZ);
drawCrosshair();
/* 姿态小标记 */
if (player.crouch !== 'stand'){
ctx.fillStyle = 'rgba(138,200,90,.75)';
ctx.font = 'bold 8px monospace';
ctx.textAlign = 'center';
ctx.fillText(player.crouch === 'crouch' ? '· 蹲伏 ·' : '· 趴下 ·', W/2, H - 18);
ctx.textAlign = 'left';
}
}
/* ---- 绘制其他玩家 ---- */
function drawNetPlayers(horizon, dirX, dirY, planeX, planeY, camZ){
if (!NET.mode) return;
const p = player;
const invDet = 1 / (planeX * dirY - dirX * planeY);
const list = [];
NET.players.forEach((pl, id) => {
if (id === NET.playerId) return;
if (!pl.lastSeen) return;
const sx = pl.x - p.x, sy = pl.y - p.y;
const tY = invDet * (-planeY * sx + planeX * sy);
if (tY <= 0.15) return;
list.push({ pl: pl, sx: sx, sy: sy, tY: tY });
});
if (!list.length) return;
list.sort((a, b) => b.tY - a.tY);
for (const item of list){
const pl = item.pl;
const tY = item.tY;
const tX = invDet * (dirY * item.sx - dirX * item.sy);
const screenX = (W / 2) * (1 + tX / tY);
const pose = pl.crouch === 'crouch' ? 'crouch' : (pl.crouch === 'prone' ? 'prone' : 'stand');
const tex = PLAYER_SPRITES[pose];
const worldH = pose === 'prone' ? 0.35 : (pose === 'crouch' ? 0.7 : 1.0);
const worldW = pose === 'prone' ? 0.8 : (pose === 'crouch' ? 0.55 : 0.6);
const plZ = pl.z || 0;
const lineH = (H / tY) * worldH;
const bottomY = horizon + (H / tY) * (camZ - plZ);
const topY = bottomY - lineH;
const spriteW = (H / tY) * worldW;
const drawStartX = Math.floor(screenX - spriteW / 2);
const drawEndX = Math.floor(screenX + spriteW / 2);
const sw = tex.width, sh = tex.height;
for (let stripe = drawStartX; stripe < drawEndX; stripe++){
if (stripe < 0 || stripe >= W) continue;
if (tY >= zBuffer[stripe]) continue;
const texX = Math.floor((stripe - drawStartX) * sw / spriteW);
if (texX < 0 || texX >= sw) continue;
const sTopY = Math.max(0, Math.floor(topY));
const sLineH = Math.max(1, Math.min(H - sTopY, Math.ceil(lineH)));
if (sLineH > 0) ctx.drawImage(tex, texX, 0, 1, sh, stripe, sTopY, 1, sLineH);
const shade = tY / 20;
if (shade > 0.02){
ctx.fillStyle = 'rgba(0,0,0,' + Math.min(0.55, shade).toFixed(3) + ')';
ctx.fillRect(stripe, sTopY, 1, sLineH);
}
}
/* 名字 */
if (tY < 16){
const nameY = Math.max(9, topY - 4);
ctx.font = 'bold 7px monospace';
ctx.textAlign = 'center';
const txt = pl.name || '玩家';
const w = ctx.measureText(txt).width;
ctx.fillStyle = 'rgba(0,0,0,.55)';
ctx.fillRect(screenX - w/2 - 2, nameY - 7, w + 4, 9);
ctx.fillStyle = '#8ac85a';
ctx.fillText(txt, screenX, nameY);
ctx.textAlign = 'left';
}
}
}
/* ---- 地面投射 ---- */
function castFloor(horizon, dirX, dirY, planeX, planeY, camZ){
const hy = Math.max(0, Math.floor(horizon));
const fh = H - hy;
if (fh <= 0) return;
const data = floorImage.data;
const rayDirX0 = dirX - planeX, rayDirY0 = dirY - planeY;
const rayDirX1 = dirX + planeX, rayDirY1 = dirY + planeY;
const px = player.x, py = player.y;
for (let sy = 0; sy < fh; sy += 2){
const screenY = hy + sy;
const pp = screenY - horizon;
if (pp <= 0) continue;
const rowDistance = (camZ * H) / pp;
const stepX = rowDistance * (rayDirX1 - rayDirX0) / W;
const stepY = rowDistance * (rayDirY1 - rayDirY0) / W;
let fx = px + rowDistance * rayDirX0;
let fy = py + rowDistance * rayDirY0;
const fog = Math.min(0.55, rowDistance / 40);
for (let x = 0; x < W; x++){
const cellX = Math.floor(fx), cellY = Math.floor(fy);
const pat = ((Math.floor(fx*2) + Math.floor(fy*2)) & 1);
let r, g, b;
if (cellX >= 0 && cellY >= 0 && cellX < MAP_SIZE && cellY < MAP_SIZE && map[cellY][cellX] > 0){
const tile = map[cellY][cellX];
if (tile === 6){ r = 132 + pat*8; g = 112 + pat*8; b = 76 + pat*8; }
else { r = 92 + pat*4; g = 86 + pat*4; b = 76 + pat*4; }
} else {
r = 48 + pat*10; g = 72 + pat*12; b = 32 + pat*8;
if (cellX >= 0 && cellY >= 0 && cellX < MAP_SIZE && cellY < MAP_SIZE){
let nearWall = false;
if (map[cellY][Math.min(MAP_SIZE-1, cellX+1)] >= 2) nearWall = true;
if (map[cellY][Math.max(0, cellX-1)] >= 2) nearWall = true;
if (map[Math.min(MAP_SIZE-1, cellY+1)][cellX] >= 2) nearWall = true;
if (map[Math.max(0, cellY-1)][cellX] >= 2) nearWall = true;
if (nearWall){ r = 58+pat*8; g = 62+pat*8; b = 44+pat*8; }
}
}
const inv = 1 - fog;
r = r*inv + 68*fog; g = g*inv + 82*fog; b = b*inv + 100*fog;
const idx = (sy * W + x) * 4;
data[idx] = r; data[idx+1] = g; data[idx+2] = b; data[idx+3] = 255;
fx += stepX; fy += stepY;
}
if (sy + 1 < fh){
const srcStart = sy * W * 4, dstStart = (sy+1) * W * 4;
for (let i = 0; i < W*4; i++) data[dstStart+i] = data[srcStart+i];
}
}
ctx.putImageData(floorImage, 0, hy, 0, 0, W, fh);
}
/* ---- 准星 ---- */
function drawCrosshair(){
const cx = W/2, cy = H/2;
const gap = 4, len = 4;
ctx.fillStyle = 'rgba(255,255,255,.7)';
ctx.fillRect(cx - 1, cy - gap - len, 2, len);
ctx.fillRect(cx - 1, cy + gap, 2, len);
ctx.fillRect(cx - gap - len, cy - 1, len, 2);
ctx.fillRect(cx + gap, cy - 1, len, 2);
ctx.fillStyle = 'rgba(138,200,90,.9)';
ctx.fillRect(cx - 1, cy - 1, 2, 2);
}
/* ============================================================
21. 设置面板
============================================================ */
function bindSlider(id, valId, key, fmt){
const el = $(id), valEl = $(valId);
el.addEventListener('input', () => {
settings[key] = parseFloat(el.value);
valEl.textContent = fmt ? fmt(settings[key]) : settings[key];
saveSettings();
});
}
bindSlider('sensHip', 'sensHipVal', 'sensHip');
bindSlider('sensY', 'sensYVal', 'sensY');
bindSlider('pitchRange', 'pitchRangeVal', 'pitchRange', v => v + '°');
function syncSettingsUI(){
$('sensHip').value = settings.sensHip; $('sensHipVal').textContent = settings.sensHip;
$('sensY').value = settings.sensY; $('sensYVal').textContent = settings.sensY;
$('pitchRange').value = settings.pitchRange; $('pitchRangeVal').textContent = settings.pitchRange + '°';
}
setBtn.addEventListener('click', () => {
setPanel.classList.toggle('show');
if (setPanel.classList.contains('show')) syncSettingsUI();
});
$('setClose').addEventListener('click', () => setPanel.classList.remove('show'));
/* ============================================================
22. 主循环
============================================================ */
let lastT = performance.now();
function loop(now){
const dt = Math.min(0.05, (now - lastT) / 1000);
lastT = now;
if (game.state !== 'lobby'){
update(dt);
render();
}
requestAnimationFrame(loop);
}
/* ============================================================
23. 初始化
============================================================ */
initNet();
layoutKeys();
syncSettingsUI();
resize();
requestAnimationFrame(loop);
</script>
</body>
</html>Game Source: 移动测试场 · 联机
Creator: SwiftGalaxy49
Libraries: none
Complexity: complex (2236 lines, 76.5 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: game-swiftgalaxy49-mvbzb6ek" to link back to the original. Then publish at arcadelab.ai/publish.