🎮ArcadeLab

猫武士 · 自由探索

by TurboKoala83
1702 lines59.1 KB🛠️ Three.js (3D graphics)
▶ 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="#0a1016">
<title>猫武士 · 自由探索</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
html,body{
  width:100%;height:100%;overflow:hidden;background:#0a1016;
  font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
  -webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;
  touch-action:none;overscroll-behavior:none;
}
#c{position:fixed;inset:0;display:block;width:100%;height:100%;}

#hud{position:fixed;inset:0;pointer-events:none;z-index:10;}
#topLeft{position:absolute;top:calc(14px + env(safe-area-inset-top));left:14px;}
.bar{width:42vw;max-width:170px;height:13px;background:rgba(0,0,0,.5);
  border-radius:8px;border:1px solid rgba(255,255,255,.28);overflow:hidden;margin-bottom:7px;}
.bar i{display:block;height:100%;width:100%;border-radius:7px;transition:width .1s linear;}
#hpFill{background:linear-gradient(90deg,#ff4d4d,#ff9a5a);}
#stFill{background:linear-gradient(90deg,#4fc3ff,#7dffc4);}
#stWrap{height:8px;width:32vw;max-width:130px;}
#topRight{position:absolute;top:calc(14px + env(safe-area-inset-top));right:16px;
  text-align:right;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,.7);}
#score{font-size:26px;font-weight:800;letter-spacing:1px;line-height:1;}
#waveLbl{font-size:12px;opacity:.85;margin-top:4px;letter-spacing:1px;}
#waveTip{position:absolute;top:26%;left:0;right:0;text-align:center;color:#fff;
  font-size:30px;font-weight:800;letter-spacing:4px;opacity:0;transition:opacity .5s;
  text-shadow:0 4px 18px rgba(0,0,0,.8);}

#stick{position:absolute;left:22px;bottom:calc(34px + env(safe-area-inset-bottom));
  width:132px;height:132px;border-radius:50%;pointer-events:auto;touch-action:none;
  background:radial-gradient(circle,rgba(255,255,255,.13),rgba(255,255,255,.03));
  border:2px solid rgba(255,255,255,.3);
  box-shadow:0 0 22px rgba(0,0,0,.35) inset;}
#knob{position:absolute;left:50%;top:50%;width:58px;height:58px;margin:-29px 0 0 -29px;
  border-radius:50%;pointer-events:none;
  background:radial-gradient(circle at 35% 30%,rgba(255,255,255,.92),rgba(160,210,255,.55));
  box-shadow:0 4px 16px rgba(0,0,0,.45);}

#btns{position:absolute;right:20px;bottom:calc(34px + env(safe-area-inset-bottom));
  display:flex;align-items:flex-end;gap:12px;pointer-events:none;}
.btn{pointer-events:auto;touch-action:none;border-radius:50%;color:#fff;
  font-weight:800;display:flex;align-items:center;justify-content:center;
  border:2px solid rgba(255,255,255,.38);text-shadow:0 2px 6px rgba(0,0,0,.65);
  transition:transform .07s;font-family:inherit;}
.btn:active{transform:scale(.92);}
#atkBtn{width:90px;height:90px;font-size:19px;letter-spacing:2px;
  background:radial-gradient(circle at 35% 30%,rgba(255,150,95,.75),rgba(190,55,40,.62));}
#jumpBtn{width:62px;height:62px;font-size:13px;letter-spacing:1px;
  background:radial-gradient(circle at 35% 30%,rgba(175,255,185,.72),rgba(38,140,72,.62));}
#dashBtn{width:62px;height:62px;font-size:13px;letter-spacing:1px;
  background:radial-gradient(circle at 35% 30%,rgba(140,200,255,.78),rgba(40,90,180,.62));}
.btn.cool{opacity:.4;}

#hurt{position:fixed;inset:0;pointer-events:none;z-index:15;opacity:0;
  background:radial-gradient(circle,rgba(255,0,0,0) 35%,rgba(255,20,20,.85) 100%);}

#minimap{position:absolute;top:calc(14px + env(safe-area-inset-top));right:16px;
  width:110px;height:110px;background:rgba(0,0,0,.4);border-radius:8px;
  border:1px solid rgba(255,255,255,.2);overflow:hidden;}
#minimap canvas{width:100%;height:100%;display:block;}

.overlay{position:fixed;inset:0;z-index:20;display:flex;flex-direction:column;
  align-items:center;justify-content:center;padding:26px;color:#fff;text-align:center;
  background:radial-gradient(circle at 50% 38%,rgba(28,52,72,.9),rgba(4,9,14,.97));
  backdrop-filter:blur(3px);}
.overlay.hide{display:none;}
.overlay h1{font-size:40px;letter-spacing:8px;font-weight:900;
  background:linear-gradient(180deg,#fff,#8fd8ff);-webkit-background-clip:text;
  background-clip:text;color:transparent;text-shadow:0 0 40px rgba(120,200,255,.35);}
.sub{font-size:12px;letter-spacing:4px;opacity:.6;margin-top:8px;}
.howto{margin:28px 0 30px;font-size:14px;line-height:2.05;opacity:.82;}
.howto p{letter-spacing:1px;}
.bigbtn{pointer-events:auto;font-family:inherit;font-size:18px;font-weight:800;letter-spacing:4px;
  color:#08131c;padding:15px 46px;border:none;border-radius:40px;cursor:pointer;
  background:linear-gradient(180deg,#a8ecff,#4fb8ff);
  box-shadow:0 8px 26px rgba(70,180,255,.45);}
.bigbtn:active{transform:scale(.96);}
#finalScore{font-size:15px;opacity:.85;margin:6px 0 26px;letter-spacing:2px;}
#finalScore b{font-size:30px;color:#ffd479;display:block;margin-top:8px;letter-spacing:1px;}
</style>
</head>
<body>
<canvas id="c"></canvas>

<div id="hud">
  <div id="topLeft">
    <div class="bar"><i id="hpFill"></i></div>
    <div class="bar" id="stWrap"><i id="stFill"></i></div>
  </div>
  <div id="topRight">
    <div id="score">0</div>
    <div id="waveLbl">自由探索</div>
  </div>
  <div id="waveTip"></div>
  <div id="minimap"><canvas id="minimapCanvas" width="110" height="110"></canvas></div>

  <div id="stick"><div id="knob"></div></div>
  <div id="btns">
    <button class="btn" id="jumpBtn">跳跃</button>
    <button class="btn" id="dashBtn">冲刺</button>
    <button class="btn" id="atkBtn">爪击</button>
  </div>
</div>

<div id="hurt"></div>

<div class="overlay" id="overlay">
  <h1>猫武士</h1>
  <div class="sub">FOREST GUARDIAN</div>
  <div class="howto">
    <p>🐾 左侧摇杆控制移动</p>
    <p>⚔️ 右下「爪击」攻击</p>
    <p>💨 「冲刺」四拍疾驰,快速位移</p>
    <p>🦘 「跳跃」翻越障碍</p>
    <p>🌿 拾取草药恢复生命</p>
  </div>
  <button class="bigbtn" id="startBtn">进入小镇</button>
</div>

<div class="overlay hide" id="gameover">
  <h1 style="font-size:32px;letter-spacing:6px;">探索结束</h1>
  <div id="finalScore">你的战果<b>0</b></div>
  <button class="bigbtn" id="restartBtn">再次出征</button>
</div>

<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
'use strict';

/* ============================================================
   1. 渲染器 / 场景 / 相机
   ============================================================ */
const canvas   = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({canvas, antialias:true, powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

const scene = new THREE.Scene();
scene.background = new THREE.Color(0xa8c8e0);
scene.fog = new THREE.Fog(0xa8c8e0, 55, 115);

const camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 300);
// 🌟 相机拉远拉高:看得见更大的环境
const CAM_OFFSET = new THREE.Vector3(0, 8, 13);
const CAM_LOOK_Y = 1.2;
camera.position.copy(CAM_OFFSET);
camera.lookAt(0, CAM_LOOK_Y, 0);
const camLook = new THREE.Vector3(0, CAM_LOOK_Y, 0);

scene.add(new THREE.HemisphereLight(0xdfeaff, 0x3a4a29, 0.95));
const sun = new THREE.DirectionalLight(0xfff2d8, 0.9);
sun.position.set(30, 45, 25);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -50; sun.shadow.camera.right = 50;
sun.shadow.camera.top = 50; sun.shadow.camera.bottom = -50;
sun.shadow.camera.near = 1; sun.shadow.camera.far = 150;
scene.add(sun);

window.addEventListener('resize', function(){
  camera.aspect = window.innerWidth/window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

/* ============================================================
   2. 小镇地图(放大版:房子是猫的 4-5 倍高)
   
   猫肩高约 1.5 单位
   房子高 5~8 单位(猫的 3.3~5.3 倍)
   树高 5~7 单位
   路灯高 3 单位
   ============================================================ */
const WORLD_HALF = 38;
const circleObstacles = [];
const rectObstacles = [];

(function buildTown(){
  const groundGeo = new THREE.PlaneGeometry(WORLD_HALF*2, WORLD_HALF*2);
  groundGeo.rotateX(-Math.PI/2);
  scene.add(new THREE.Mesh(groundGeo, new THREE.MeshLambertMaterial({color:0x5a7d3a})));

  const patchGeo = new THREE.CircleGeometry(1, 8);
  patchGeo.rotateX(-Math.PI/2);
  for (let i=0;i<50;i++){
    const a = Math.random()*Math.PI*2, r = Math.random()*36;
    const s = 1 + Math.random()*3;
    const col = Math.random() < 0.5 ? 0x517238 : 0x608840;
    const p = new THREE.Mesh(patchGeo, new THREE.MeshLambertMaterial({color:col}));
    p.position.set(Math.cos(a)*r, 0.01, Math.sin(a)*r);
    p.scale.set(s, 1, s);
    scene.add(p);
  }

  const roadMat = new THREE.MeshLambertMaterial({color:0x554e42});
  const roadEdgeMat = new THREE.MeshLambertMaterial({color:0x6b6253});
  const mainRoadW = 6;

  const roadH = new THREE.Mesh(new THREE.PlaneGeometry(WORLD_HALF*2, mainRoadW), roadMat);
  roadH.rotation.x = -Math.PI/2;
  roadH.position.y = 0.02;
  scene.add(roadH);

  const roadV = new THREE.Mesh(new THREE.PlaneGeometry(mainRoadW, WORLD_HALF*2), roadMat);
  roadV.rotation.x = -Math.PI/2;
  roadV.position.y = 0.021;
  scene.add(roadV);

  function addEdge(x, z, w, d){
    const e = new THREE.Mesh(new THREE.PlaneGeometry(w, d), roadEdgeMat);
    e.rotation.x = -Math.PI/2;
    e.position.set(x, 0.025, z);
    scene.add(e);
  }
  addEdge(0,  mainRoadW/2, WORLD_HALF*2, 0.15);
  addEdge(0, -mainRoadW/2, WORLD_HALF*2, 0.15);
  addEdge( mainRoadW/2, 0, 0.15, WORLD_HALF*2);
  addEdge(-mainRoadW/2, 0, 0.15, WORLD_HALF*2);

  const ringR = 22;
  const ringW = 4;
  const segs = [
    { x:0, z:-ringR, w:ringR*2, d:ringW },
    { x:0, z: ringR, w:ringR*2, d:ringW },
    { x:-ringR, z:0, w:ringW, d:ringR*2 },
    { x: ringR, z:0, w:ringW, d:ringR*2 }
  ];
  segs.forEach(function(s){
    const r = new THREE.Mesh(new THREE.PlaneGeometry(s.w, s.d), roadMat);
    r.rotation.x = -Math.PI/2;
    r.position.set(s.x, 0.018, s.z);
    scene.add(r);
  });

  const plazaGeo = new THREE.CircleGeometry(mainRoadW*0.75, 16);
  plazaGeo.rotateX(-Math.PI/2);
  const plaza = new THREE.Mesh(plazaGeo, new THREE.MeshLambertMaterial({color:0x7a6e5c}));
  plaza.position.y = 0.03;
  scene.add(plaza);

  /* ---------- 🌟 建筑物:整体放大到猫的 4-5 倍高 ---------- */
  const buildingColors = [
    0x8a6f4a, 0xa0805a, 0x6e5a42, 0x9a7a55, 0x7a6250,
    0x8a7a5a, 0x6a5a45, 0xb08a60
  ];
  const roofColors = [0x5a3a2a, 0x6a4a30, 0x4a3020, 0x7a5a3a, 0x3a2a1a];

  function makeBuilding(cx, cz, w, d, h, colIdx){
    const g = new THREE.Group();

    const bodyMat = new THREE.MeshLambertMaterial({color: buildingColors[colIdx % buildingColors.length]});
    const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), bodyMat);
    body.position.y = h/2;
    body.castShadow = true;
    body.receiveShadow = true;
    g.add(body);

    const roofMat = new THREE.MeshLambertMaterial({color: roofColors[colIdx % roofColors.length]});
    const roof = new THREE.Mesh(
      new THREE.ConeGeometry(Math.max(w, d)*0.72, h*0.55, 4),
      roofMat
    );
    roof.position.y = h + h*0.27;
    roof.rotation.y = Math.PI/4;
    roof.castShadow = true;
    g.add(roof);

    // 🌟 窗户放大
    const winMat = new THREE.MeshBasicMaterial({color:0xffe8a0});
    const winCount = Math.floor(w * d / 6) + 1;
    for (let i=0;i<winCount;i++){
      const wx = (Math.random()-0.5) * (w-1.5);
      const wy = 1.2 + Math.random() * (h-2);
      const wz = (Math.random() < 0.5 ? 1 : -1) * d/2;
      const win = new THREE.Mesh(new THREE.PlaneGeometry(0.8, 1.1), winMat);
      win.position.set(wx, wy, wz*1.01);
      if (wz < 0) win.rotation.y = Math.PI;
      g.add(win);
    }

    g.position.set(cx, 0, cz);
    scene.add(g);

    rectObstacles.push({ cx: cx, cz: cz, hw: w/2, hd: d/2 });
  }

  const quadrants = [
    { cx: 17, cz: 17 },
    { cx: -17, cz: 17 },
    { cx: 17, cz: -17 },
    { cx: -17, cz: -17 }
  ];

  quadrants.forEach(function(q, qi){
    const baseX = q.cx;
    const baseZ = q.cz;
    const count = 3 + Math.floor(Math.random()*2);
    for (let i=0;i<count;i++){
      let bx = baseX + (Math.random()-0.5)*14;
      let bz = baseZ + (Math.random()-0.5)*14;
      if (Math.abs(bx) < 5.5) bx += Math.sign(bx || 1) * 5.5;
      if (Math.abs(bz) < 5.5) bz += Math.sign(bz || 1) * 5.5;
      if (Math.abs(Math.abs(bx) - ringR) < 3.5) bx += (bx > 0 ? 4 : -4);
      if (Math.abs(Math.abs(bz) - ringR) < 3.5) bz += (bz > 0 ? 4 : -4);

      // 🌟 房子放大
      const w = 4 + Math.random()*4;
      const d = 4 + Math.random()*4;
      const h = 5 + Math.random()*3;   // 5~8 高,猫的 3.3~5.3 倍
      makeBuilding(bx, bz, w, d, h, qi*2 + i);
    }
  });

  /* ---------- 地标(放大版) ---------- */
  // 东北:水池
  (function(){
    const poolGeo = new THREE.CircleGeometry(4, 24);
    poolGeo.rotateX(-Math.PI/2);
    const pool = new THREE.Mesh(poolGeo, new THREE.MeshLambertMaterial({color:0x3a7ab8}));
    pool.position.set(29, 0.04, 29);
    scene.add(pool);
    const rimGeo = new THREE.TorusGeometry(4.2, 0.3, 8, 24);
    const rim = new THREE.Mesh(rimGeo, new THREE.MeshLambertMaterial({color:0x8a8070}));
    rim.rotation.x = Math.PI/2;
    rim.position.set(29, 0.15, 29);
    scene.add(rim);
    for (let i=0;i<8;i++){
      const a = i/8 * Math.PI*2;
      circleObstacles.push({ x: 29 + Math.cos(a)*3.8, z: 29 + Math.sin(a)*3.8, r: 0.6 });
    }
  })();

  // 西北:大树
  (function(){
    const trunk = new THREE.Mesh(
      new THREE.CylinderGeometry(0.6, 0.9, 6, 8),
      new THREE.MeshLambertMaterial({color:0x4a3520})
    );
    trunk.position.set(-29, 3, 29);
    trunk.castShadow = true;
    scene.add(trunk);
    const crown = new THREE.Mesh(
      new THREE.SphereGeometry(4, 12, 10),
      new THREE.MeshLambertMaterial({color:0x3a7a32})
    );
    crown.position.set(-29, 7.5, 29);
    crown.castShadow = true;
    scene.add(crown);
    circleObstacles.push({ x:-29, z:29, r:1.0 });
  })();

  // 西南:废墟
  (function(){
    const stoneMat = new THREE.MeshLambertMaterial({color:0x9a9080});
    for (let i=0;i<6;i++){
      const a = i/6 * Math.PI*2;
      const r = 2 + Math.random()*1.5;
      const h = 1 + Math.random()*2.5;
      const stone = new THREE.Mesh(
        new THREE.CylinderGeometry(0.5, 0.6, h, 6),
        stoneMat
      );
      stone.position.set(-29 + Math.cos(a)*r, h/2, -29 + Math.sin(a)*r);
      stone.rotation.z = (Math.random()-0.5)*0.5;
      stone.castShadow = true;
      scene.add(stone);
      circleObstacles.push({ x: stone.position.x, z: stone.position.z, r: 0.65 });
    }
  })();

  // 东南:塔楼
  (function(){
    const tower = new THREE.Mesh(
      new THREE.CylinderGeometry(1.4, 1.8, 7, 8),
      new THREE.MeshLambertMaterial({color:0x8a7560})
    );
    tower.position.set(29, 3.5, -29);
    tower.castShadow = true;
    scene.add(tower);
    const top = new THREE.Mesh(
      new THREE.ConeGeometry(1.8, 2, 8),
      new THREE.MeshLambertMaterial({color:0x5a3a2a})
    );
    top.position.set(29, 8, -29);
    top.castShadow = true;
    scene.add(top);
    circleObstacles.push({ x:29, z:-29, r:2.0 });
  })();

  /* ---------- 树木(放大版) ---------- */
  const trunkMat = new THREE.MeshLambertMaterial({color:0x4a3520});
  const leafMatA = new THREE.MeshLambertMaterial({color:0x2f6b2a});
  const leafMatB = new THREE.MeshLambertMaterial({color:0x3b7d33});
  const trunkGeo = new THREE.CylinderGeometry(0.25, 0.4, 3, 6);
  const leafGeo  = new THREE.ConeGeometry(1.6, 3.5, 7);

  for (let i=0;i<55;i++){
    const a = Math.random()*Math.PI*2;
    const r = 5 + Math.random()*31;
    const x = Math.cos(a)*r, z = Math.sin(a)*r;
    if (Math.abs(x) < 4 || Math.abs(z) < 4) continue;
    if (Math.abs(Math.abs(x) - ringR) < 3 && Math.abs(z) < ringR+3) continue;
    if (Math.abs(Math.abs(z) - ringR) < 3 && Math.abs(x) < ringR+3) continue;

    const s = 0.8 + Math.random()*0.6;
    const trunk = new THREE.Mesh(trunkGeo, trunkMat);
    trunk.position.set(x, 1.5*s, z);
    trunk.scale.setScalar(s);
    trunk.castShadow = true;
    scene.add(trunk);

    const leaf = new THREE.Mesh(leafGeo, Math.random()<0.5 ? leafMatA : leafMatB);
    leaf.position.set(x, 4.2*s, z);
    leaf.scale.setScalar(s);
    leaf.castShadow = true;
    scene.add(leaf);

    circleObstacles.push({ x:x, z:z, r:0.6*s });
  }

  /* ---------- 灌木 / 石头(放大版) ---------- */
  const rockMat = new THREE.MeshLambertMaterial({color:0x7b7f83});
  const bushMat = new THREE.MeshLambertMaterial({color:0x35702c});
  const rockGeo = new THREE.IcosahedronGeometry(0.6, 0);
  const bushGeo = new THREE.SphereGeometry(0.7, 7, 6);

  for (let i=0;i<45;i++){
    const a = Math.random()*Math.PI*2, r = 4 + Math.random()*33;
    const isRock = Math.random() < 0.4;
    const s = 0.6 + Math.random()*0.7;
    const px = Math.cos(a)*r, pz = Math.sin(a)*r;
    if (Math.abs(px) < 4 || Math.abs(pz) < 4) continue;

    const m = new THREE.Mesh(isRock ? rockGeo : bushGeo, isRock ? rockMat : bushMat);
    m.position.set(px, isRock ? 0.25*s : 0.5*s, pz);
    m.scale.set(s, s*0.8, s);
    m.rotation.y = Math.random()*3;
    m.castShadow = true;
    scene.add(m);
    if (isRock) circleObstacles.push({ x:px, z:pz, r:0.55*s });
  }

  /* ---------- 路灯(放大版) ---------- */
  const poleMat = new THREE.MeshLambertMaterial({color:0x3a3028});
  const lampMat = new THREE.MeshBasicMaterial({color:0xfff0a0});
  const lampPositions = [
    [-26, -4], [-16, -4], [-6, -4], [6, -4], [16, -4], [26, -4],
    [-26,  4], [-16,  4], [-6,  4], [6,  4], [16,  4], [26,  4],
    [-4, -26], [-4, -16], [-4, -6], [-4, 6], [-4, 16], [-4, 26],
    [ 4, -26], [ 4, -16], [ 4, -6], [ 4, 6], [ 4, 16], [ 4, 26]
  ];
  lampPositions.forEach(function(p){
    const pole = new THREE.Mesh(
      new THREE.CylinderGeometry(0.08, 0.1, 3, 6),
      poleMat
    );
    pole.position.set(p[0], 1.5, p[1]);
    pole.castShadow = true;
    scene.add(pole);
    const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.22, 8, 6), lampMat);
    lamp.position.set(p[0], 3.1, p[1]);
    scene.add(lamp);
  });

})();

function insideObstacle(x, z, pad){
  const p = pad || 0;
  for (let i=0;i<circleObstacles.length;i++){
    const o = circleObstacles[i];
    const dx = x - o.x, dz = z - o.z;
    const rr = o.r + p;
    if (dx*dx + dz*dz < rr*rr) return true;
  }
  for (let i=0;i<rectObstacles.length;i++){
    const o = rectObstacles[i];
    const dx = Math.max(0, Math.abs(x - o.cx) - o.hw);
    const dz = Math.max(0, Math.abs(z - o.cz) - o.hd);
    if (dx*dx + dz*dz < p*p) return true;
  }
  return false;
}

/* ============================================================
   3. 毛皮纹理 + 毛簇
   ============================================================ */
function createFurTexture(baseColor, stripeColor) {
  const canvas = document.createElement('canvas');
  canvas.width = 256; canvas.height = 256;
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = baseColor; ctx.fillRect(0, 0, 256, 256);

  ctx.strokeStyle = stripeColor; ctx.lineWidth = 10;
  ctx.lineCap = 'round'; ctx.lineJoin = 'round';
  for (let i = 0; i < 8; i++) {
    ctx.beginPath();
    const startY = Math.random() * 256;
    ctx.moveTo(-25, startY);
    ctx.bezierCurveTo(75, startY - 20, 175, startY + 20, 281, startY - 10);
    ctx.stroke();
  }
  ctx.strokeStyle = stripeColor; ctx.lineWidth = 7;
  for (let i = 0; i < 2; i++) {
    const cx = 60 + Math.random()*135, cy = 60 + Math.random()*135;
    const r = 15 + Math.random()*20;
    ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 1.5); ctx.stroke();
    ctx.beginPath(); ctx.arc(cx + r*0.3, cy - r*0.3, r*0.4, 0, Math.PI*2); ctx.stroke();
  }

  const texture = new THREE.CanvasTexture(canvas);
  texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping;
  return texture;
}

const furTexOrange = createFurTexture('#E69A4E', '#B0682F');

function addFurToEllipsoid(parent, cx, cy, cz, rx, ry, rz, count, matMain, matDark, opts) {
  opts = opts || {};
  const sizeMin = opts.sizeMin || 0.06, sizeMax = opts.sizeMax || 0.12;
  const embedMin = opts.embedMin || 0.75, embedMax = opts.embedMax || 0.92;
  const bellyDroop = opts.bellyDroop !== undefined ? opts.bellyDroop : 0.5;
  const backDensity = opts.backDensity !== undefined ? opts.backDensity : 0.15;
  const bellyDensity = opts.bellyDensity !== undefined ? opts.bellyDensity : 0.7;

  let placed = 0, safety = 0;
  while (placed < count && safety < count * 30) {
    safety++;
    const theta = Math.random()*Math.PI*2, phi = Math.acos(2*Math.random()-1);
    const nx = Math.sin(phi)*Math.cos(theta), ny = Math.cos(phi), nz = Math.sin(phi)*Math.sin(theta);

    let probability;
    if (ny > 0.6) probability = backDensity;
    else if (ny > -0.3) probability = 1.0;
    else probability = bellyDensity;
    if (Math.random() > probability) continue;

    const embed = embedMin + Math.random()*(embedMax-embedMin);
    const px = cx + nx*rx*embed, py = cy + ny*ry*embed, pz = cz + nz*rz*embed;

    const w = sizeMin + Math.random()*(sizeMax-sizeMin);
    const h = sizeMin + Math.random()*(sizeMax-sizeMin);
    const d = sizeMin + Math.random()*(sizeMax-sizeMin);

    const mat = Math.random() < 0.15 ? matDark : matMain;
    const fur = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
    fur.position.set(px, py, pz);

    const nxN = nx/rx, nyN = ny/ry, nzN = nz/rz;
    const nlen = Math.sqrt(nxN*nxN + nyN*nyN + nzN*nzN);
    let dirX = nxN/nlen, dirY = nyN/nlen, dirZ = nzN/nlen;

    if (ny < -0.2) {
      dirY -= bellyDroop*0.6*Math.abs(ny);
      const l2 = Math.sqrt(dirX*dirX + dirY*dirY + dirZ*dirZ);
      dirX/=l2; dirY/=l2; dirZ/=l2;
    }

    const up = new THREE.Vector3(0,1,0), dir = new THREE.Vector3(dirX,dirY,dirZ);
    const quat = new THREE.Quaternion().setFromUnitVectors(up, dir);
    fur.quaternion.copy(quat);
    fur.rotateX((Math.random()-0.5)*1.2);
    fur.rotateZ((Math.random()-0.5)*1.2);

    parent.add(fur); placed++;
  }
}

/* ============================================================
   4. 腿部构建
   ============================================================ */
function createLeg(parent, opts) {
  const upperPivot = new THREE.Group();
  upperPivot.position.set(opts.x, 0.78, opts.z);
  upperPivot.rotation.x = opts.tiltUpper;
  parent.add(upperPivot);

  const upperGeo = new THREE.CylinderGeometry(opts.upperR1, opts.upperR0, opts.upperLen, 12);
  upperGeo.translate(0, -opts.upperLen/2, 0);
  const upper = new THREE.Mesh(upperGeo, opts.matBody);
  upper.castShadow = true;
  upperPivot.add(upper);

  const lowerPivot = new THREE.Group();
  lowerPivot.position.y = -opts.upperLen;
  lowerPivot.rotation.x = opts.tiltLower;
  upperPivot.add(lowerPivot);

  const lowerGeo = new THREE.CylinderGeometry(opts.lowerR1, opts.lowerR0, opts.lowerLen, 12);
  lowerGeo.translate(0, -opts.lowerLen/2, 0);
  const lower = new THREE.Mesh(lowerGeo, opts.matBody);
  lower.castShadow = true;
  lowerPivot.add(lower);

  const pawPivot = new THREE.Group();
  pawPivot.position.set(0, -opts.lowerLen, 0);
  lowerPivot.add(pawPivot);

  const paw = new THREE.Mesh(opts.pawGeo, opts.matLight);
  paw.scale.set(1.0, 0.7, 1.35);
  paw.position.set(0, -0.02, 0.05);
  paw.rotation.x = 0.12;
  pawPivot.add(paw);

  const clawPivot = new THREE.Group();
  clawPivot.position.set(0, -0.03, 0.13);
  pawPivot.add(clawPivot);

  for (let t = -1; t <= 1; t++) {
    const clawGeo = new THREE.ConeGeometry(0.016, 0.085, 4);
    const claw = new THREE.Mesh(clawGeo, opts.matDark);
    claw.position.set(t * 0.042, 0, 0);
    claw.rotation.x = Math.PI / 2 + 0.3;
    claw.rotation.z = t * 0.12;
    clawPivot.add(claw);
  }
  clawPivot.visible = false;

  return { upper: upperPivot, lower: lowerPivot, paw: pawPivot, claw: clawPivot };
}

/* ============================================================
   5. 猫模型
   ============================================================ */
function createCat(opts){
  opts = opts || {};
  const C_MAIN  = opts.main  !== undefined ? opts.main  : 0xE69A4E;
  const C_LIGHT = opts.light !== undefined ? opts.light : 0xF6DEBE;
  const C_DARK  = opts.dark  !== undefined ? opts.dark  : 0xB0682F;
  const C_EYE   = opts.eye   !== undefined ? opts.eye   : 0x9BF25B;
  const FUR_TEXTURE = opts.texture !== undefined ? opts.texture : furTexOrange;
  const DENSITY = opts.density !== undefined ? opts.density : 1.0;
  const SCALE = opts.scale !== undefined ? opts.scale : 1.0;

  const g = new THREE.Group();
  const C_LINE = 0x3A2318;

  const matBody  = new THREE.MeshLambertMaterial({ map: FUR_TEXTURE });
  const matMain  = new THREE.MeshLambertMaterial({color: C_MAIN});
  const matLight = new THREE.MeshLambertMaterial({color: C_LIGHT});
  const matDark  = new THREE.MeshLambertMaterial({color: C_DARK});
  const matLine  = new THREE.MeshLambertMaterial({color: C_LINE});
  const matEyeW  = new THREE.MeshBasicMaterial({color: 0xFFFFFF});
  const matEyeG  = new THREE.MeshBasicMaterial({color: C_EYE});
  const matEyeB  = new THREE.MeshBasicMaterial({color: 0x101014});

  const bodyPivot = new THREE.Group();
  bodyPivot.position.set(0, 0.80, 0);
  g.add(bodyPivot);

  const body = new THREE.Mesh(new THREE.SphereGeometry(0.5, 20, 16), matBody);
  body.scale.set(0.82, 0.68, 1.15);
  body.castShadow = true;
  bodyPivot.add(body);

  const belly = new THREE.Mesh(new THREE.SphereGeometry(0.42, 16, 12), matLight);
  belly.scale.set(0.80, 0.72, 1.05);
  belly.position.set(0, -0.08, 0.05);
  bodyPivot.add(belly);

  const hip = new THREE.Mesh(new THREE.SphereGeometry(0.42, 16, 12), matBody);
  hip.scale.set(0.85, 0.75, 0.90);
  hip.position.set(0, 0.02, -0.50);
  hip.castShadow = true;
  bodyPivot.add(hip);

  const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.38, 16, 12), matBody);
  shoulder.scale.set(0.85, 0.80, 0.95);
  shoulder.position.set(0, 0.02, 0.45);
  shoulder.castShadow = true;
  bodyPivot.add(shoulder);

  const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.32, 0.35, 12), matBody);
  neck.position.set(0, 0.25, 0.42);
  neck.rotation.x = -0.32;
  neck.castShadow = true;
  bodyPivot.add(neck);

  if (DENSITY > 0){
    addFurToEllipsoid(bodyPivot, 0, 0, 0, 0.41, 0.34, 0.575, Math.round(100*DENSITY), matMain, matDark, {
      sizeMin: 0.14, sizeMax: 0.26, embedMin: 0.72, embedMax: 0.88,
      backDensity: 0.15, bellyDensity: 0.7, bellyDroop: 0.8 });
    addFurToEllipsoid(bodyPivot, 0, 0.02, 0.45, 0.323, 0.304, 0.361, Math.round(40*DENSITY), matMain, matDark, {
      sizeMin: 0.12, sizeMax: 0.20, embedMin: 0.72, embedMax: 0.88,
      backDensity: 0.3, bellyDensity: 0.6, bellyDroop: 0.6 });
    addFurToEllipsoid(bodyPivot, 0, 0.25, 0.42, 0.28, 0.20, 0.28, Math.round(30*DENSITY), matMain, matDark, {
      sizeMin: 0.10, sizeMax: 0.18, embedMin: 0.72, embedMax: 0.88,
      backDensity: 0.4, bellyDensity: 0.6, bellyDroop: 0.5 });
  }

  const matHead = new THREE.MeshLambertMaterial({color: C_MAIN});
  const headPivot = new THREE.Group();
  headPivot.position.set(0, 0.45, 0.78);
  bodyPivot.add(headPivot);

  const head = new THREE.Mesh(new THREE.SphereGeometry(0.38, 20, 16), matHead);
  head.scale.set(1.08, 0.92, 0.98);
  head.castShadow = true;
  headPivot.add(head);

  const chin = new THREE.Mesh(new THREE.SphereGeometry(0.24, 12, 10), matHead);
  chin.scale.set(0.85, 0.65, 0.90);
  chin.position.set(0, -0.12, 0.14);
  headPivot.add(chin);

  [-1, 1].forEach(function(sx){
    const baseY = 0.03, spacingY = 0.065;
    for (let i = 0; i < 4; i++){
      const w = 0.12+Math.random()*0.04, h = 0.15+Math.random()*0.04, d = 0.11+Math.random()*0.04;
      const spike = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), matHead);
      spike.position.set(0.32*sx, baseY - i*spacingY, (Math.random()-0.5)*0.05);
      spike.rotation.z = -sx*0.2;
      spike.rotation.y = sx*0.5;
      spike.rotation.x = (Math.random()-0.5)*0.4;
      headPivot.add(spike);
    }
    const bottomSpike = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.16, 0.12), matHead);
    bottomSpike.position.set(0.32*sx, baseY - 4*spacingY, 0);
    bottomSpike.rotation.z = -sx*0.15;
    bottomSpike.rotation.y = sx*0.4;
    headPivot.add(bottomSpike);
  });

  const muzzle = new THREE.Mesh(new THREE.SphereGeometry(0.19, 14, 12), matLight);
  muzzle.scale.set(1.20, 0.75, 1.0);
  muzzle.position.set(0, -0.13, 0.27);
  headPivot.add(muzzle);

  const nose = new THREE.Mesh(new THREE.ConeGeometry(0.055, 0.065, 4), matLine);
  nose.rotation.x = Math.PI; nose.rotation.y = Math.PI/4;
  nose.position.set(0, -0.09, 0.42);
  headPivot.add(nose);

  const mouthGeo = new THREE.TorusGeometry(0.06, 0.008, 4, 8, Math.PI);
  [-1, 1].forEach(function(sx){
    const m = new THREE.Mesh(mouthGeo, matLine);
    m.rotation.z = Math.PI/2; m.rotation.y = Math.PI;
    m.position.set(0.055*sx, -0.18, 0.38);
    headPivot.add(m);
  });

  [-1, 1].forEach(function(sx){
    const eyeW = new THREE.Mesh(new THREE.SphereGeometry(0.115, 14, 10), matEyeW);
    eyeW.scale.set(1.0, 0.75, 0.45);
    eyeW.position.set(0.19*sx, 0.10, 0.28);
    eyeW.rotation.z = 0.40*sx;
    headPivot.add(eyeW);

    const eyeG = new THREE.Mesh(new THREE.SphereGeometry(0.092, 12, 10), matEyeG);
    eyeG.scale.set(0.92, 0.80, 0.45);
    eyeG.position.set(0.19*sx, 0.10, 0.31);
    eyeG.rotation.z = 0.40*sx;
    headPivot.add(eyeG);

    const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.042, 8, 6), matEyeB);
    pupil.scale.set(0.40, 1.1, 0.5);
    pupil.position.set(0.19*sx, 0.10, 0.35);
    headPivot.add(pupil);

    const spark = new THREE.Mesh(new THREE.SphereGeometry(0.022, 6, 5), matEyeW);
    spark.position.set(0.16*sx, 0.14, 0.37);
    headPivot.add(spark);
  });

  [-1, 1].forEach(function(sx){
    const ear = new THREE.Mesh(new THREE.ConeGeometry(0.18, 0.50, 8), matHead);
    ear.position.set(0.28*sx, 0.47, -0.06);
    ear.rotation.z = -0.20*sx;
    ear.rotation.x = -0.12;
    ear.castShadow = true;
    headPivot.add(ear);

    const inner = new THREE.Mesh(new THREE.ConeGeometry(0.10, 0.30, 8), matDark);
    inner.position.set(0, -0.03, 0.05);
    ear.add(inner);

    const tuft = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.18, 6), matDark);
    tuft.position.set(0, 0.22, 0);
    tuft.rotation.x = 0.12;
    ear.add(tuft);
  });

  [-1, 1].forEach(function(sx){
    for (let i = 0; i < 3; i++){
      const s = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.16, 4), matDark);
      s.rotation.x = Math.PI/2;
      s.rotation.z = -0.35*sx;
      s.position.set(0.09*sx, 0.33 - i*0.02, 0.07 - i*0.06);
      headPivot.add(s);
    }
  });

  const pawGeo = new THREE.SphereGeometry(0.105, 10, 8);
  const legs = [];

  [1, -1].forEach(function(sign){
    const leg = createLeg(g, {
      x: 0.28*sign, z: 0.5,
      upperR0: 0.105, upperR1: 0.09, upperLen: 0.32,
      lowerR0: 0.085, lowerR1: 0.072, lowerLen: 0.36,
      tiltUpper: 0.12, tiltLower: -0.20,
      matBody: matBody, matLight: matLight, matDark: matDark, pawGeo: pawGeo
    });
    legs.push(leg);

    if (DENSITY > 0){
      addFurToEllipsoid(bodyPivot, 0.28*sign, 0.02, 0.5, 0.13, 0.13, 0.13, Math.round(15*DENSITY), matMain, matDark, {
        sizeMin: 0.09, sizeMax: 0.16, embedMin: 0.70, embedMax: 0.88,
        backDensity: 0.2, bellyDensity: 0.7, bellyDroop: 0.6 });
    }
  });

  [1, -1].forEach(function(sign){
    const leg = createLeg(g, {
      x: 0.30*sign, z: -0.68,
      upperR0: 0.16, upperR1: 0.13, upperLen: 0.36,
      lowerR0: 0.095, lowerR1: 0.075, lowerLen: 0.34,
      tiltUpper: 0.15, tiltLower: -0.28,
      matBody: matBody, matLight: matLight, matDark: matDark, pawGeo: pawGeo
    });
    legs.push(leg);

    if (DENSITY > 0){
      addFurToEllipsoid(bodyPivot, 0.30*sign, 0.02, -0.68, 0.16, 0.16, 0.16, Math.round(18*DENSITY), matMain, matDark, {
        sizeMin: 0.10, sizeMax: 0.18, embedMin: 0.70, embedMax: 0.88,
        backDensity: 0.2, bellyDensity: 0.7, bellyDroop: 0.6 });
    }
  });

  const tailSegs = [
    { len: 0.30, r0: 0.100, r1: 0.088, ang: -0.75 },
    { len: 0.32, r0: 0.088, r1: 0.075, ang: 0.35 },
    { len: 0.32, r0: 0.075, r1: 0.062, ang: 0.35 },
    { len: 0.30, r0: 0.062, r1: 0.050, ang: 0.3 },
    { len: 0.28, r0: 0.050, r1: 0.038, ang: 0.25 }
  ];

  const tailRoot = new THREE.Group();
  tailRoot.position.set(0, 0.85, -0.55);
  g.add(tailRoot);

  const tailPivots = [];
  let attach = tailRoot;

  tailSegs.forEach(function(s, i){
    const pivot = new THREE.Group();
    if (i > 0) pivot.position.y = tailSegs[i-1].len;
    pivot.rotation.x = s.ang;
    const isTip = (i === tailSegs.length - 1);

    const geo = new THREE.CylinderGeometry(s.r1, s.r0, s.len, 10);
    geo.translate(0, s.len/2, 0);
    const mesh = new THREE.Mesh(geo, isTip ? matLight : matBody);
    mesh.castShadow = true;
    pivot.add(mesh);

    if (DENSITY > 0){
      let furCount = Math.round((24 - i*2) * DENSITY);
      const furMatMain = new THREE.MeshLambertMaterial({color: C_MAIN});
      const furMatLight = new THREE.MeshLambertMaterial({color: C_LIGHT});

      for (let j = 0; j < furCount; j++) {
        const angle = (j/furCount)*Math.PI*2 + (Math.random()-0.5)*0.6;
        const sizeMult = isTip ? 0.75 : (1.0 - (i/tailSegs.length)*0.25);
        const w = (0.13+Math.random()*0.10)*sizeMult;
        const h = (0.20+Math.random()*0.20)*sizeMult;
        const d = (0.13+Math.random()*0.10)*sizeMult;
        const fur = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), (i >= 3) ? furMatLight : furMatMain);

        const rOffset = s.r0 * (0.5 + Math.random()*0.5);
        const yOffset = isTip ? (s.len*0.5 + Math.random()*s.len*0.8) : (s.len*0.05 + Math.random()*s.len*0.9);
        fur.position.set(Math.cos(angle)*rOffset, yOffset, Math.sin(angle)*rOffset);

        const tilt = isTip ? (0.15 + Math.random()*0.25) : (0.25 + Math.random()*0.4);
        fur.rotation.z = -Math.cos(angle)*tilt;
        fur.rotation.x = Math.sin(angle)*tilt;
        fur.rotation.y = (Math.random()-0.5)*1.0;
        pivot.add(fur);
      }

      if (isTip) {
        const tipFurCount = Math.round(10 * DENSITY);
        for (let k = 0; k < tipFurCount; k++) {
          const w = 0.12+Math.random()*0.10, h = 0.18+Math.random()*0.20, d = 0.12+Math.random()*0.10;
          const fur = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), furMatLight);
          const rOffset = s.r1 * (0.3 + Math.random()*0.9);
          const angle = Math.random()*Math.PI*2;
          fur.position.set(Math.cos(angle)*rOffset, s.len - 0.06 + Math.random()*0.16, Math.sin(angle)*rOffset);
          fur.rotation.z = (Math.random()-0.5)*1.6;
          fur.rotation.x = (Math.random()-0.5)*1.6;
          fur.rotation.y = Math.random()*Math.PI*2;
          pivot.add(fur);
        }
        const centerFur = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.28, 0.15), furMatLight);
        centerFur.position.set(0, s.len + 0.07, 0);
        centerFur.rotation.set(0.3, 0.5, -0.2);
        pivot.add(centerFur);
      }
    } else if (isTip) {
      const cap = new THREE.Mesh(new THREE.SphereGeometry(s.r1 * 1.15, 10, 8), matLight);
      cap.position.y = s.len;
      pivot.add(cap);
    }

    attach.add(pivot);
    attach = pivot;
    tailPivots.push(pivot);
  });

  g.scale.setScalar(SCALE);

  return { group: g, bodyPivot, headPivot, legs, tail: tailPivots };
}

/* ============================================================
   6. 玩家实例
   ============================================================ */
const PLAYER_RADIUS = 0.72;
const PC = createCat({
  main: 0xE69A4E, light: 0xF6DEBE, dark: 0xB0682F, eye: 0x9BF25B,
  texture: furTexOrange, density: 0.7, scale: 1.15
});
PC.group.position.set(0, 0, 0);
scene.add(PC.group);

const P = {
  yaw: 0, phase: 0,
  vy: 0, onGround: true,
  dashTime: 0, dashCd: 0,
  invuln: 0,
  walkTime: 0
};

const playerAnim = {
  mode: 'idle',
  attack: {
    active: false,
    timer: 0,
    duration: 0.75,
    cooldown: 0,
    cooldownMax: 0.35,
    hitSet: new Set()
  }
};

const LEG_RF = 1, LEG_LF = 0, LEG_LH = 2, LEG_RH = 3;

/* ============================================================
   7. 草药
   ============================================================ */
const herbs = [];

function spawnHerbAt(x, z){
  const g = new THREE.Group();
  const bulb = new THREE.Mesh(
    new THREE.IcosahedronGeometry(0.3, 0),
    new THREE.MeshLambertMaterial({color:0x6dffa0, emissive:0x1c5c32})
  );
  bulb.position.y = 0.55;
  g.add(bulb);
  const stem = new THREE.Mesh(
    new THREE.CylinderGeometry(0.05, 0.07, 0.5, 5),
    new THREE.MeshLambertMaterial({color:0x2f7a3f})
  );
  stem.position.y = 0.25;
  g.add(stem);
  g.position.set(x, 0, z);
  scene.add(g);
  herbs.push({group:g, bulb:bulb, t:Math.random()*6});
}

function spawnHerbNearPlayer(){
  for (let tries = 0; tries < 20; tries++){
    const a = Math.random()*Math.PI*2;
    const r = 4 + Math.random()*10;
    let x = PC.group.position.x + Math.cos(a)*r;
    let z = PC.group.position.z + Math.sin(a)*r;
    const rad = Math.hypot(x, z);
    if (rad > WORLD_HALF - 3){
      const k = (WORLD_HALF - 3) / rad;
      x *= k; z *= k;
    }
    if (!insideObstacle(x, z, 0.6)){
      spawnHerbAt(x, z);
      return;
    }
  }
}

/* ============================================================
   8. 游戏状态
   ============================================================ */
const G = {
  running: false, over: false,
  hp: 100, maxHp: 100,
  stamina: 100,
  score: 0,
  time: 0,
  herbTimer: 6
};

/* ============================================================
   9. 输入
   ============================================================ */
const moveInput = {x:0, y:0};
const stickEl = document.getElementById('stick');
const knobEl  = document.getElementById('knob');
const STICK_R = 52;
let stickPointer = null;

function stickUpdate(e){
  const r = stickEl.getBoundingClientRect();
  const cx = r.left + r.width/2, cy = r.top + r.height/2;
  let dx = e.clientX - cx, dy = e.clientY - cy;
  const len = Math.hypot(dx, dy);
  if (len > STICK_R){ dx = dx/len*STICK_R; dy = dy/len*STICK_R; }
  knobEl.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
  if (len < 8){ moveInput.x = 0; moveInput.y = 0; }
  else { moveInput.x = dx/STICK_R; moveInput.y = dy/STICK_R; }
}

stickEl.addEventListener('pointerdown', function(e){
  e.preventDefault();
  if (stickPointer !== null) return;
  stickPointer = e.pointerId;
  try{ stickEl.setPointerCapture(e.pointerId); }catch(err){}
  stickUpdate(e);
});
stickEl.addEventListener('pointermove', function(e){
  if (e.pointerId !== stickPointer) return;
  e.preventDefault();
  stickUpdate(e);
});
function stickEnd(e){
  if (e.pointerId !== stickPointer) return;
  stickPointer = null;
  moveInput.x = 0; moveInput.y = 0;
  knobEl.style.transform = 'translate(0px,0px)';
}
stickEl.addEventListener('pointerup', stickEnd);
stickEl.addEventListener('pointercancel', stickEnd);

const atkBtn = document.getElementById('atkBtn');
let attackHeld = false;
atkBtn.addEventListener('pointerdown', function(e){
  e.preventDefault(); attackHeld = true;
  try{ atkBtn.setPointerCapture(e.pointerId); }catch(err){}
});
atkBtn.addEventListener('pointerup', function(){ attackHeld = false; });
atkBtn.addEventListener('pointercancel', function(){ attackHeld = false; });

const jumpBtn = document.getElementById('jumpBtn');
jumpBtn.addEventListener('pointerdown', function(e){
  e.preventDefault();
  tryJump();
});

const dashBtn = document.getElementById('dashBtn');
dashBtn.addEventListener('pointerdown', function(e){
  e.preventDefault();
  tryDash();
});

document.addEventListener('touchmove', function(e){ e.preventDefault(); }, {passive:false});
document.addEventListener('gesturestart', function(e){ e.preventDefault(); });

/* ============================================================
   10. 工具函数
   ============================================================ */
function lerpAngle(a, b, t){
  let d = (b - a) % (Math.PI*2);
  if (d >  Math.PI) d -= Math.PI*2;
  if (d < -Math.PI) d += Math.PI*2;
  return a + d*t;
}

function resolveCollisions(pos, radius){
  for (let pass=0; pass<2; pass++){
    for (let i=0;i<circleObstacles.length;i++){
      const o = circleObstacles[i];
      const dx = pos.x - o.x;
      const dz = pos.z - o.z;
      const minD = o.r + radius;
      const d2 = dx*dx + dz*dz;
      if (d2 < minD*minD){
        const d = Math.sqrt(d2) || 0.0001;
        const push = (minD - d) / d;
        pos.x += dx * push;
        pos.z += dz * push;
      }
    }
    for (let i=0;i<rectObstacles.length;i++){
      const o = rectObstacles[i];
      const closestX = Math.max(o.cx - o.hw, Math.min(pos.x, o.cx + o.hw));
      const closestZ = Math.max(o.cz - o.hd, Math.min(pos.z, o.cz + o.hd));
      const dx = pos.x - closestX;
      const dz = pos.z - closestZ;
      const d2 = dx*dx + dz*dz;
      if (d2 < radius*radius){
        if (d2 < 0.0001){
          const leftD = pos.x - (o.cx - o.hw);
          const rightD = (o.cx + o.hw) - pos.x;
          const topD = pos.z - (o.cz - o.hd);
          const botD = (o.cz + o.hd) - pos.z;
          const minD = Math.min(leftD, rightD, topD, botD);
          if (minD === leftD) pos.x = o.cx - o.hw - radius;
          else if (minD === rightD) pos.x = o.cx + o.hw + radius;
          else if (minD === topD) pos.z = o.cz - o.hd - radius;
          else pos.z = o.cz + o.hd + radius;
        } else {
          const d = Math.sqrt(d2);
          const push = (radius - d) / d;
          pos.x += dx * push;
          pos.z += dz * push;
        }
      }
    }
  }
  pos.x = Math.max(-WORLD_HALF + 1, Math.min(WORLD_HALF - 1, pos.x));
  pos.z = Math.max(-WORLD_HALF + 1, Math.min(WORLD_HALF - 1, pos.z));
}

/* ============================================================
   11. 动画驱动
   ============================================================ */
function compensatePaw(leg, upperRot, lowerRot, factor) {
  const f = factor !== undefined ? factor : 0.8;
  leg.paw.rotation.x = -(upperRot + lowerRot) * f + 0.12;
}

function playWalk(cat, t, speedScale){
  const speed = 0.8 * (speedScale || 1);
  const phase = t * speed;
  const phases = [0.75, 0.25, 0.5, 0.0];
  for (let i = 0; i < cat.legs.length; i++){
    const leg = cat.legs[i];
    const p = (phase + phases[i]) % 1.0;
    const upperSwing = Math.sin(p * Math.PI * 2) * 0.55;
    const lowerSwing = Math.sin(p * Math.PI * 2 - 0.6) * 0.33;
    const upperRot = 0.12 + upperSwing;
    const lowerRot = -0.20 + lowerSwing;
    leg.upper.rotation.x = upperRot;
    leg.lower.rotation.x = lowerRot;
    compensatePaw(leg, upperRot, lowerRot, 0.8);
  }
  const bobY = Math.sin(phase * Math.PI * 4) * 0.03;
  const swayX = Math.sin(phase * Math.PI * 2) * 0.06;
  cat.group.position.y = bobY;
  cat.group.rotation.z = swayX * 0.3;
  cat.bodyPivot.rotation.y = Math.sin(phase * Math.PI * 2) * 0.04;
  cat.bodyPivot.rotation.x = Math.sin(phase * Math.PI * 4 + 1.0) * 0.02;
  cat.headPivot.rotation.x = -cat.bodyPivot.rotation.x * 0.5;
  cat.headPivot.rotation.z = -swayX * 0.5;
  const tailBases = [-0.75, 0.35, 0.35, 0.3, 0.25];
  for (let i = 0; i < cat.tail.length; i++){
    const p = i * 0.35;
    cat.tail[i].rotation.x = tailBases[i] + Math.sin(phase * Math.PI * 2 - p) * 0.08;
    cat.tail[i].rotation.z = Math.sin(phase * Math.PI * 2 - p + 1.2) * 0.056;
  }
}

function playGallop(cat, t){
  const phase = t * 2.0;
  const phases = [0.75, 0.25, 0.5, 0.0];
  for (let i = 0; i < cat.legs.length; i++){
    const leg = cat.legs[i];
    const p = (phase + phases[i]) % 1.0;
    const upperSwing = Math.sin(p * Math.PI * 2) * 1.0;
    let lowerSwing;
    if (i < 2) lowerSwing = Math.sin(p * Math.PI * 2 + 0.9) * 0.9;
    else lowerSwing = Math.sin(p * Math.PI * 2 + 1.3) * 1.1;
    const upperRot = 0.12 + upperSwing;
    const lowerRot = -0.20 + lowerSwing;
    leg.upper.rotation.x = upperRot;
    leg.lower.rotation.x = lowerRot;
    compensatePaw(leg, upperRot, lowerRot, 0.85);
  }
  const bobY = Math.sin(phase * Math.PI * 4) * 0.12;
  const leapY = Math.max(0, Math.sin(phase * Math.PI * 2)) * 0.15;
  cat.group.position.y = bobY + leapY;
  const swayX = Math.sin(phase * Math.PI * 2) * 0.03;
  cat.group.rotation.z = swayX * 0.15;
  const spineWave = Math.sin(phase * Math.PI * 2);
  cat.bodyPivot.rotation.x = spineWave * 0.18;
  cat.bodyPivot.rotation.y = Math.sin(phase * Math.PI * 2 + 0.5) * 0.04;
  cat.headPivot.rotation.x = -cat.bodyPivot.rotation.x * 0.7;
  cat.headPivot.rotation.z = -swayX * 0.3;
  const tailBases = [-0.75, 0.35, 0.35, 0.3, 0.25];
  for (let i = 0; i < cat.tail.length; i++){
    const p = i * 0.3;
    const runBase = tailBases[i] - 0.2;
    cat.tail[i].rotation.x = runBase + Math.sin(phase * Math.PI * 2 - p) * 0.05;
    cat.tail[i].rotation.z = Math.sin(phase * Math.PI * 2 - p + 1.0) * 0.025;
  }
}

function playBound(cat, t){
  const phase = t * 1.8;
  for (let i = 0; i < cat.legs.length; i++){
    const leg = cat.legs[i];
    const isFront = (i < 2);
    const p = (phase + (isFront ? 0.0 : 0.5)) % 1.0;
    const upperSwing = Math.sin(p * Math.PI * 2) * 0.9;
    let lowerSwing;
    if (isFront) lowerSwing = Math.sin(p * Math.PI * 2 + 0.5) * 0.72;
    else lowerSwing = Math.sin(p * Math.PI * 2 + 1.0) * 1.08;
    const upperRot = 0.12 + upperSwing;
    const lowerRot = -0.20 + lowerSwing;
    leg.upper.rotation.x = upperRot;
    leg.lower.rotation.x = lowerRot;
    compensatePaw(leg, upperRot, lowerRot, 0.85);
  }
  const leapY = Math.max(0, Math.sin(phase * Math.PI * 2)) * 0.2;
  const bobY = Math.sin(phase * Math.PI * 4) * 0.08;
  cat.group.position.y = bobY + leapY;
  const swayX = Math.sin(phase * Math.PI * 2) * 0.02;
  cat.group.rotation.z = swayX * 0.1;
  const spineWave = Math.sin(phase * Math.PI * 2);
  cat.bodyPivot.rotation.x = spineWave * 0.25;
  cat.headPivot.rotation.x = -cat.bodyPivot.rotation.x * 0.8;
  cat.headPivot.rotation.z = -swayX * 0.2;
  const tailBases = [-0.75, 0.35, 0.35, 0.3, 0.25];
  for (let i = 0; i < cat.tail.length; i++){
    const p = i * 0.3;
    cat.tail[i].rotation.x = tailBases[i] - 0.15 + Math.sin(phase * Math.PI * 2 - p) * 0.04;
    cat.tail[i].rotation.z = Math.sin(phase * Math.PI * 2 - p + 1.0) * 0.02;
  }
}

function playIdle(cat, t){
  for (let i = 0; i < cat.legs.length; i++){
    const leg = cat.legs[i];
    leg.upper.rotation.x = 0.12;
    leg.lower.rotation.x = -0.20;
    compensatePaw(leg, 0.12, -0.20, 0.8);
  }
  cat.group.position.y = Math.sin(t * 1.4) * 0.015;
  cat.group.rotation.z = 0;
  cat.bodyPivot.rotation.x = 0;
  cat.bodyPivot.rotation.y = 0;
  cat.headPivot.rotation.x = 0;
  cat.headPivot.rotation.z = 0;
  const tailBases = [-0.75, 0.35, 0.35, 0.3, 0.25];
  for (let i = 0; i < cat.tail.length; i++){
    const p = i * 0.35;
    cat.tail[i].rotation.x = tailBases[i] + Math.sin(t * 2.0 - p) * 0.06;
    cat.tail[i].rotation.z = Math.sin(t * 1.6 - p + 1.2) * 0.05;
  }
}

function playAttack(cat, p){
  const RF_BASE = { u: 0.12, l: -0.20, p: 0.12 };
  const HIND_BASE = { u: 0.15, l: -0.28, p: 0.12 };

  let rfU, rfL, rfP, clawOpen;
  let lfU, lfL, lfP;
  let hindU, hindL, hindP;
  let bodyPitch, bodyYaw, bodyRoll, bodyY, bodyZ;
  let headPitch, headYaw;

  if (p < 0.4) {
    const k = p / 0.4;
    const ease = Math.sin(k * Math.PI * 0.5);
    rfU = RF_BASE.u - 1.6 * ease;
    rfL = RF_BASE.l - 1.15 * ease;
    rfP = RF_BASE.p - 0.85 * ease;
    clawOpen = 0.85 * ease;
    lfU = 0.12 + 0.15 * ease;
    lfL = -0.20 - 0.10 * ease;
    lfP = 0.12 - 0.05 * ease;
    hindU = HIND_BASE.u + 0.45 * ease;
    hindL = HIND_BASE.l - 0.55 * ease;
    hindP = HIND_BASE.p + 0.15 * ease;
    bodyPitch = 0.22 * ease;
    bodyYaw = 0.25 * ease;
    bodyRoll = -0.10 * ease;
    bodyY = -0.02 * ease;
    bodyZ = -0.05 * ease;
    headPitch = 0.22 * ease;
    headYaw = 0.15 * ease;
  } else if (p < 0.65) {
    const k = (p - 0.4) / 0.25;
    const ease = k * k * (3 - 2 * k);
    const rfUStart = RF_BASE.u - 1.6;
    const rfLEnd   = RF_BASE.l - 1.15;
    const rfPEnd   = RF_BASE.p - 0.85;
    rfU = rfUStart + (2.9 * ease);
    rfL = rfLEnd + (1.95 * ease);
    rfP = rfPEnd + (1.55 * ease);
    clawOpen = 1.0;
    lfU = 0.27 - 0.05 * ease;
    lfL = -0.30 + 0.05 * ease;
    lfP = 0.07;
    hindU = 0.60 - 0.25 * ease;
    hindL = -0.83 + 0.25 * ease;
    hindP = 0.27 - 0.05 * ease;
    bodyPitch = 0.22 - 0.42 * ease;
    bodyYaw = 0.25 - 0.40 * ease;
    bodyRoll = -0.10 + 0.15 * ease;
    bodyY = -0.02 + 0.04 * ease;
    bodyZ = -0.05 + 0.12 * ease;
    headPitch = 0.22 - 0.20 * ease;
    headYaw = 0.15 - 0.22 * ease;
  } else {
    const k = (p - 0.65) / 0.35;
    const ease = Math.sin(k * Math.PI * 0.5);
    const rfUEnd = RF_BASE.u - 1.6 + 2.9;
    const rfLEnd = RF_BASE.l - 1.15 + 1.95;
    const rfPEnd = RF_BASE.p - 0.85 + 1.55;
    rfU = rfUEnd + (RF_BASE.u - rfUEnd) * ease;
    rfL = rfLEnd + (RF_BASE.l - rfLEnd) * ease;
    rfP = rfPEnd + (RF_BASE.p - rfPEnd) * ease;
    clawOpen = 1.0 - ease;
    lfU = 0.12 + 0.10 * (1 - ease);
    lfL = -0.20 - 0.05 * (1 - ease);
    lfP = 0.12;
    hindU = 0.15 + 0.20 * (1 - ease);
    hindL = -0.28 - 0.30 * (1 - ease);
    hindP = 0.12;
    bodyPitch = -0.20 * (1 - ease);
    bodyYaw = -0.15 * (1 - ease);
    bodyRoll = 0.05 * (1 - ease);
    bodyY = 0.02 * (1 - ease);
    bodyZ = 0.07 * (1 - ease);
    headPitch = 0.02 * (1 - ease);
    headYaw = -0.07 * (1 - ease);
  }

  const legRF = cat.legs[LEG_RF];
  legRF.upper.rotation.x = rfU;
  legRF.lower.rotation.x = rfL;
  legRF.paw.rotation.x = rfP;
  legRF.claw.visible = clawOpen > 0.05;
  legRF.claw.rotation.x = -clawOpen * 0.6;
  legRF.claw.children.forEach(function(claw, idx){
    claw.rotation.z = (idx - 1) * 0.12 * (1 + clawOpen * 0.8);
  });

  const legLF = cat.legs[LEG_LF];
  legLF.upper.rotation.x = lfU;
  legLF.lower.rotation.x = lfL;
  legLF.paw.rotation.x = lfP;

  const legLH = cat.legs[LEG_LH];
  const legRH = cat.legs[LEG_RH];
  [legLH, legRH].forEach(function(leg){
    leg.upper.rotation.x = hindU;
    leg.lower.rotation.x = hindL;
    leg.paw.rotation.x = hindP;
  });

  cat.bodyPivot.rotation.x = bodyPitch;
  cat.bodyPivot.rotation.y = bodyYaw;
  cat.bodyPivot.rotation.z = bodyRoll;
  cat.bodyPivot.position.y = 0.80 + bodyY;
  cat.bodyPivot.position.z = bodyZ;
  cat.headPivot.rotation.x = headPitch;
  cat.headPivot.rotation.y = headYaw;

  const tailBases = [-0.75, 0.35, 0.35, 0.3, 0.25];
  const tailSwing = bodyYaw * 1.5;
  for (let i = 0; i < cat.tail.length; i++){
    const pi = i * 0.3;
    cat.tail[i].rotation.x = tailBases[i] + Math.sin(p * 30 - pi) * 0.08;
    cat.tail[i].rotation.z = tailSwing * (1 + pi * 0.5);
  }
}

/* ============================================================
   12. 玩家逻辑
   ============================================================ */
const GRAVITY = -25;
const JUMP_VELOCITY = 12;

const DASH_SPEED = 11;
const DASH_DURATION = 0.8;
const DASH_COOLDOWN = 0.5;
const DASH_STAMINA_COST = 30;

function tryJump(){
  if (!G.running || !P.onGround) return;
  P.vy = JUMP_VELOCITY;
  P.onGround = false;
}

function tryDash(){
  if (!G.running) return;
  if (P.dashTime > 0) return;
  if (P.dashCd > 0) return;
  if (G.stamina < DASH_STAMINA_COST) return;
  G.stamina -= DASH_STAMINA_COST;
  P.dashTime = DASH_DURATION;
  P.dashCd = DASH_COOLDOWN;
}

function triggerAttack(){
  if (playerAnim.attack.active || playerAnim.attack.cooldown > 0) return;
  playerAnim.attack.active = true;
  playerAnim.attack.timer = 0;
  playerAnim.attack.hitSet.clear();
}

function updatePlayer(dt){
  let mag = Math.hypot(moveInput.x, moveInput.y);
  if (mag < 0.13) mag = 0;
  if (mag > 1) mag = 1;
  if (playerAnim.attack.active) mag = 0;

  const dashing = P.dashTime > 0;
  if (dashing) P.dashTime -= dt;
  if (P.dashCd > 0) P.dashCd -= dt;

  let speed = 4.5;
  if (dashing) speed = DASH_SPEED;

  let moveDirX = 0, moveDirZ = 0;
  let didMove = false;

  if (mag > 0){
    moveDirX = moveInput.x;
    moveDirZ = moveInput.y;
    const l = Math.hypot(moveDirX, moveDirZ) || 1;
    moveDirX /= l;
    moveDirZ /= l;
    const targetYaw = Math.atan2(moveDirX, moveDirZ);
    P.yaw = lerpAngle(P.yaw, targetYaw, 1 - Math.exp(-18*dt));
    didMove = true;
  } else if (dashing){
    moveDirX = Math.sin(P.yaw);
    moveDirZ = Math.cos(P.yaw);
    didMove = true;
    mag = 1;
  }

  if (didMove){
    const sp = speed * mag * dt;
    PC.group.position.x += moveDirX * sp;
    PC.group.position.z += moveDirZ * sp;
  }

  resolveCollisions(PC.group.position, PLAYER_RADIUS);

  if (!P.onGround){
    P.vy += GRAVITY * dt;
    PC.group.position.y += P.vy * dt;
    if (PC.group.position.y <= 0){
      PC.group.position.y = 0;
      P.vy = 0;
      P.onGround = true;
    }
  }

  PC.group.rotation.y = P.yaw;

  let currentSpeed = 0;
  if (didMove) currentSpeed = speed * mag;

  if (playerAnim.attack.active){
    const p = Math.min(1, playerAnim.attack.timer / playerAnim.attack.duration);
    playAttack(PC, p);
    playerAnim.attack.timer += dt;
    if (p >= 1){
      playerAnim.attack.active = false;
      playerAnim.attack.cooldown = playerAnim.attack.cooldownMax;
      PC.bodyPivot.position.y = 0.80;
      PC.bodyPivot.position.z = 0;
      PC.bodyPivot.rotation.z = 0;
      PC.headPivot.rotation.y = 0;
      const rf = PC.legs[LEG_RF];
      rf.claw.visible = false;
      rf.claw.rotation.x = 0;
    }
  } else if (!P.onGround){
    P.walkTime += dt;
    playBound(PC, P.walkTime);
  } else if (dashing){
    P.walkTime += dt * (currentSpeed / 6);
    playGallop(PC, P.walkTime);
  } else if (currentSpeed > 0.5){
    P.walkTime += dt * (currentSpeed / 4.5);
    playWalk(PC, P.walkTime, 1.0);
  } else {
    playIdle(PC, G.time);
  }

  if (playerAnim.attack.cooldown > 0) playerAnim.attack.cooldown -= dt;
  if (P.invuln > 0)  P.invuln -= dt;
  if (G.stamina < 100) G.stamina = Math.min(100, G.stamina + 24*dt);

  if (attackHeld && !playerAnim.attack.active && playerAnim.attack.cooldown <= 0) triggerAttack();
}

/* ============================================================
   13. 草药
   ============================================================ */
function updateHerbs(dt){
  G.herbTimer -= dt;
  if (G.herbTimer <= 0 && herbs.length < 4){
    G.herbTimer = 9 + Math.random()*7;
    spawnHerbNearPlayer();
  }

  for (let i = herbs.length - 1; i >= 0; i--){
    const h = herbs[i];
    h.t += dt;
    h.bulb.rotation.y += dt * 2;
    h.bulb.position.y = 0.55 + Math.sin(h.t * 2.5) * 0.1;

    const dx = h.group.position.x - PC.group.position.x;
    const dz = h.group.position.z - PC.group.position.z;
    if (Math.hypot(dx, dz) < 1.4){
      scene.remove(h.group);
      herbs.splice(i, 1);
      G.hp = Math.min(G.maxHp, G.hp + 25);
      G.score += 5;
    }
  }
}

/* ============================================================
   14. 相机
   ============================================================ */
const _tmpDesired = new THREE.Vector3();
const _tmpLook    = new THREE.Vector3();

function updateCamera(dt){
  const tp = PC.group.position;
  _tmpDesired.set(tp.x, tp.y * 0.55, tp.z).add(CAM_OFFSET);
  const lerpSpd = P.dashTime > 0 ? 11 : 7;
  camera.position.lerp(_tmpDesired, 1 - Math.exp(-lerpSpd*dt));
  _tmpLook.set(tp.x, tp.y + CAM_LOOK_Y, tp.z);
  camLook.lerp(_tmpLook, 1 - Math.exp(-9*dt));
  camera.lookAt(camLook);
}

/* ============================================================
   15. HUD + 小地图
   ============================================================ */
const hpFillEl  = document.getElementById('hpFill');
const stFillEl  = document.getElementById('stFill');
const scoreEl   = document.getElementById('score');
const waveEl    = document.getElementById('waveLbl');
const hurtEl    = document.getElementById('hurt');
const overlayEl = document.getElementById('overlay');
const overEl    = document.getElementById('gameover');
const finalEl   = document.getElementById('finalScore');
const minimapCanvas = document.getElementById('minimapCanvas');
const minimapCtx = minimapCanvas.getContext('2d');

function updateHUD(){
  hpFillEl.style.width = Math.max(0, G.hp / G.maxHp * 100) + '%';
  stFillEl.style.width = Math.max(0, G.stamina) + '%';
  scoreEl.textContent  = G.score;
  waveEl.textContent   = '自由探索';

  if (playerAnim.attack.active || playerAnim.attack.cooldown > 0) {
    atkBtn.classList.add('cool');
  } else {
    atkBtn.classList.remove('cool');
  }
  if (P.dashTime > 0 || P.dashCd > 0 || G.stamina < DASH_STAMINA_COST) {
    dashBtn.classList.add('cool');
  } else {
    dashBtn.classList.remove('cool');
  }

  drawMinimap();
}

function drawMinimap(){
  const W = minimapCanvas.width;
  const H = minimapCanvas.height;
  minimapCtx.clearRect(0, 0, W, H);

  const scale = W / (WORLD_HALF * 2);

  minimapCtx.fillStyle = 'rgba(60,90,50,0.9)';
  minimapCtx.fillRect(0, 0, W, H);

  minimapCtx.fillStyle = 'rgba(85,78,66,1)';
  minimapCtx.fillRect(0, (WORLD_HALF - 3) * scale, W, 6 * scale);
  minimapCtx.fillRect((WORLD_HALF - 3) * scale, 0, 6 * scale, H);
  const ringR = 22, ringW = 4;
  minimapCtx.fillRect((WORLD_HALF - ringR - ringW/2) * scale, (WORLD_HALF - ringR) * scale, ringR*2 * scale, ringW * scale);
  minimapCtx.fillRect((WORLD_HALF - ringR - ringW/2) * scale, (WORLD_HALF + ringR - ringW/2) * scale, ringR*2 * scale, ringW * scale);
  minimapCtx.fillRect((WORLD_HALF - ringR - ringW/2) * scale, (WORLD_HALF - ringR) * scale, ringW * scale, ringR*2 * scale);
  minimapCtx.fillRect((WORLD_HALF + ringR - ringW/2) * scale, (WORLD_HALF - ringR) * scale, ringW * scale, ringR*2 * scale);

  minimapCtx.fillStyle = 'rgba(138,111,74,1)';
  rectObstacles.forEach(function(r){
    const x = (r.cx + WORLD_HALF - r.hw) * scale;
    const y = (r.cz + WORLD_HALF - r.hd) * scale;
    minimapCtx.fillRect(x, y, r.hw*2*scale, r.hd*2*scale);
  });

  const px = (PC.group.position.x + WORLD_HALF) * scale;
  const py = (PC.group.position.z + WORLD_HALF) * scale;
  minimapCtx.save();
  minimapCtx.translate(px, py);
  minimapCtx.rotate(-P.yaw);
  minimapCtx.fillStyle = '#ffffff';
  minimapCtx.beginPath();
  minimapCtx.moveTo(0, -4);
  minimapCtx.lineTo(-3, 3);
  minimapCtx.lineTo(3, 3);
  minimapCtx.closePath();
  minimapCtx.fill();
  minimapCtx.restore();
}

function flashHurt(){
  hurtEl.style.transition = 'none';
  hurtEl.style.opacity = '0.9';
  requestAnimationFrame(function(){
    hurtEl.style.transition = 'opacity 0.42s ease-out';
    hurtEl.style.opacity = '0';
  });
}

/* ============================================================
   16. 流程控制
   ============================================================ */
function resetGame(){
  for (let i=herbs.length-1;i>=0;i--) scene.remove(herbs[i].group);
  herbs.length = 0;

  P.yaw = 0; P.phase = 0;
  P.vy = 0; P.onGround = true;
  P.dashTime = 0; P.dashCd = 0; P.invuln = 0;
  P.walkTime = 0;

  playerAnim.attack.active = false;
  playerAnim.attack.timer = 0;
  playerAnim.attack.cooldown = 0;
  playerAnim.attack.hitSet.clear();

  PC.group.position.set(0, 0, 0);
  PC.group.rotation.y = 0;
  PC.bodyPivot.position.set(0, 0.80, 0);
  PC.bodyPivot.rotation.set(0, 0, 0);
  PC.headPivot.rotation.set(0, 0, 0);

  G.hp = G.maxHp;
  G.stamina = 100;
  G.score = 0;
  G.time = 0;
  G.herbTimer = 6;
  G.over = false;

  camera.position.set(0, CAM_OFFSET.y, CAM_OFFSET.z);
  camLook.set(0, CAM_LOOK_Y, 0);
  camera.lookAt(camLook);

  updateHUD();
}

function startGame(){
  resetGame();
  overlayEl.classList.add('hide');
  overEl.classList.add('hide');
  G.running = true;
}

document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('restartBtn').addEventListener('click', startGame);

/* ============================================================
   17. 主循环
   ============================================================ */
const clock = new THREE.Clock();

function tick(){
  const dt = Math.min(clock.getDelta(), 0.05);

  if (G.running){
    G.time += dt;
    updatePlayer(dt);
    updateHerbs(dt);
    updateHUD();
  } else {
    G.time += dt * 0.6;
    playIdle(PC, G.time);
  }

  updateCamera(dt);
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(tick);

})();
</script>
</body>
</html>

Game Source: 猫武士 · 自由探索

Creator: TurboKoala83

Libraries: three

Complexity: complex (1702 lines, 59.1 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-turbokoala83-munvc7hi" to link back to the original. Then publish at arcadelab.ai/publish.