🎮ArcadeLab

移动测试场 · 联机

by SwiftGalaxy49
2236 lines76.5 KB
▶ Play
<!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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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.