🎮ArcadeLab

猫武士 · 森林守卫

by TurboKoala83
1571 lines52.6 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:14px;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:96px;height:96px;font-size:20px;letter-spacing:2px;
  background:radial-gradient(circle at 35% 30%,rgba(255,150,95,.75),rgba(190,55,40,.62));}
#jumpBtn{width:66px;height:66px;font-size:14px;letter-spacing:1px;
  background:radial-gradient(circle at 35% 30%,rgba(175,255,185,.72),rgba(38,140,72,.62));}
.btn.cool{opacity:.42;}

#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%);}

.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">第 1 波</div>
  </div>
  <div id="waveTip"></div>

  <div id="stick"><div id="knob"></div></div>
  <div id="btns">
    <button class="btn" id="jumpBtn">跳跃</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(0x9ccfe8);
scene.fog = new THREE.Fog(0x9ccfe8, 40, 110);

const camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 400);
const CAM_OFFSET = new THREE.Vector3(0, 5.6, 7.6);
const CAM_LOOK_Y = 1.3;
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(0xcfeaff, 0x3a6629, 0.95));
const sun = new THREE.DirectionalLight(0xfff2d8, 0.85);
sun.position.set(28, 46, 18);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.camera.left = -20; sun.shadow.camera.right = 20;
sun.shadow.camera.top = 20; sun.shadow.camera.bottom = -20;
scene.add(sun);

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

/* ============================================================
   2. 森林场景 + 碰撞体
   ============================================================ */
const obstacles = [];

(function buildWorld(){
  const gGeo = new THREE.CircleGeometry(92, 48);
  gGeo.rotateX(-Math.PI/2);
  scene.add(new THREE.Mesh(gGeo, new THREE.MeshLambertMaterial({color:0x4d7c3a})));

  const patchGeo = new THREE.CircleGeometry(1, 8);
  patchGeo.rotateX(-Math.PI/2);
  const patchMat = new THREE.MeshLambertMaterial({color:0x416d31});
  for (let i=0;i<16;i++){
    const a = Math.random()*Math.PI*2, r = Math.random()*42;
    const s = 2 + Math.random()*5;
    const p = new THREE.Mesh(patchGeo, patchMat);
    p.position.set(Math.cos(a)*r, 0.012, Math.sin(a)*r);
    p.scale.set(s, 1, s);
    scene.add(p);
  }

  const trunkGeo = new THREE.CylinderGeometry(0.22, 0.34, 2.4, 6);
  const trunkMat = new THREE.MeshLambertMaterial({color:0x5a4230});
  const leafGeo  = new THREE.ConeGeometry(1.5, 3.6, 7);
  const leafMatA = new THREE.MeshLambertMaterial({color:0x2f6b2a});
  const leafMatB = new THREE.MeshLambertMaterial({color:0x3b7d33});

  for (let i=0;i<52;i++){
    const a = Math.random()*Math.PI*2;
    const r = 10 + Math.random()*32;
    const x = Math.cos(a)*r, z = Math.sin(a)*r;
    const s = 0.8 + Math.random()*0.8;
    const trunk = new THREE.Mesh(trunkGeo, trunkMat);
    trunk.position.set(x, 1.2*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.1*s, z);
    leaf.scale.setScalar(s);
    leaf.castShadow = true;
    scene.add(leaf);
    obstacles.push({ x:x, z:z, r:0.62*s });
  }

  const rockGeo = new THREE.IcosahedronGeometry(0.6, 0);
  const rockMat = new THREE.MeshLambertMaterial({color:0x7b7f83});
  const bushGeo = new THREE.SphereGeometry(0.7, 7, 6);
  const bushMat = new THREE.MeshLambertMaterial({color:0x35702c});

  for (let i=0;i<26;i++){
    const a = Math.random()*Math.PI*2, r = 8 + Math.random()*36;
    const isRock = Math.random() < 0.45;
    const s = 0.5 + Math.random()*0.9;
    const px = Math.cos(a)*r, pz = Math.sin(a)*r;
    const m = new THREE.Mesh(isRock ? rockGeo : bushGeo, isRock ? rockMat : bushMat);
    m.position.set(px, isRock ? 0.2*s : 0.45*s, pz);
    m.scale.set(s, s*0.8, s);
    m.rotation.y = Math.random()*3;
    m.castShadow = true;
    scene.add(m);
    if (isRock) obstacles.push({ x:px, z:pz, r:0.6*s });
  }
})();

function insideObstacle(x, z, pad){
  const p = pad || 0;
  for (let i=0;i<obstacles.length;i++){
    const o = obstacles[i];
    const dx = x - o.x, dz = z - o.z;
    const rr = o.r + p;
    if (dx*dx + dz*dz < rr*rr) 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');
const furTexDark   = createFurTexture('#5a4a62', '#2f2638');

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);

  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);

    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);

    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);

    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);
    }

    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',       // idle / walk / gallop / bound / attack
  attack: {
    active: false,
    timer: 0,
    duration: 0.75,
    cooldown: 0,
    cooldownMax: 0.35,
    hitSet: new Set()
  }
};

/* 攻击时的四肢参考:legs[0]=左前, [1]=右前, [2]=左后, [3]=右后 */
const LEG_RF = 1, LEG_LF = 0, LEG_LH = 2, LEG_RH = 3;

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

function spawnEnemy(){
  const a = Math.random()*Math.PI*2;
  const d = 17 + Math.random()*8;
  let x = PC.group.position.x + Math.cos(a)*d;
  let z = PC.group.position.z + Math.sin(a)*d;
  const rad = Math.hypot(x, z);
  if (rad > 44){ x *= 44/rad; z *= 44/rad; }

  const cat = createCat({
    main: 0x5a4a62, light: 0x7a6a82, dark: 0x2f2638, eye: 0xff4a4a,
    texture: furTexDark, density: 0.35, scale: 1.1
  });
  cat.group.position.set(x, 0, z);
  scene.add(cat.group);

  const hp = 60 + G.wave*14;
  enemies.push({
    cat: cat,
    hp: hp, maxHp: hp,
    speed: 3.0 + G.wave*0.16,
    atkCd: 0.8 + Math.random()*0.8,
    phase: Math.random()*6,
    knock: new THREE.Vector3(),
    flash: 0,
    dead: false,
    deadTimer: 0,
    yaw: 0,
    walkTime: 0,
    moving: 1,
    mats: [cat.bodyPivot.children[0].material]
  });
}

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 < 14; tries++){
    const a = Math.random()*Math.PI*2;
    const r = 8 + Math.random()*12;
    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 > 40){ x *= 40/rad; z *= 40/rad; }
    if (!insideObstacle(x, z, 1.0)){
      spawnHerbAt(x, z);
      return;
    }
  }
}

/* ============================================================
   8. 游戏状态
   ============================================================ */
const G = {
  running: false, over: false,
  hp: 100, maxHp: 100,
  stamina: 100,
  score: 0,
  wave: 1,
  time: 0,
  toSpawn: 0, spawnTimer: 0,
  resting: false, restTimer: 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();
});

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<obstacles.length;i++){
      const o = obstacles[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;
      }
    }
  }
  const rad = Math.hypot(pos.x, pos.z);
  if (rad > 43){
    pos.x *= 43/rad;
    pos.z *= 43/rad;
  }
}

/* ============================================================
   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){
  const breathe = Math.sin(t * 1.4) * 0.5 + 0.5;
  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 = -22;
const JUMP_VELOCITY = 8.6;

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

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;

  let speed = 5.8;
  const dashing = P.dashTime > 0;
  if (dashing){ speed = 12.5; P.dashTime -= dt; }

  if (mag > 0){
    let dx = moveInput.x, dz = moveInput.y;
    const l = Math.hypot(dx, dz) || 1;
    dx /= l; dz /= l;
    const targetYaw = Math.atan2(dx, dz);
    P.yaw = lerpAngle(P.yaw, targetYaw, 1 - Math.exp(-18*dt));
    const sp = speed * mag * dt;
    PC.group.position.x += dx * sp;
    PC.group.position.z += dz * 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 (mag > 0) 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){
    // 空中用 bound 动画
    P.walkTime += dt;
    playBound(PC, P.walkTime);
  } else if (currentSpeed > 8){
    // 冲刺 / 高速:疾驰
    P.walkTime += dt;
    playGallop(PC, P.walkTime);
  } else if (currentSpeed > 0.5){
    // 普通移动:行走
    P.walkTime += dt * (currentSpeed / 5.8);
    playWalk(PC, P.walkTime, 1.0);
  } else {
    // 待机
    playIdle(PC, G.time);
  }

  /* 计时器 */
  if (playerAnim.attack.cooldown > 0) playerAnim.attack.cooldown -= dt;
  if (P.dashCd > 0)  P.dashCd -= 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();
}

/* 攻击命中判定 */
function checkAttackHits(){
  const atk = playerAnim.attack;
  if (!atk.active) return;
  const p = atk.timer / atk.duration;
  // 只在挥击阶段判定
  if (p < 0.4 || p > 0.65) return;

  const px = PC.group.position.x, pz = PC.group.position.z;
  const fx = Math.sin(P.yaw), fz = Math.cos(P.yaw);

  for (let i = enemies.length - 1; i >= 0; i--){
    const e = enemies[i];
    if (e.dead) continue;
    if (atk.hitSet.has(i)) continue;

    const dx = e.cat.group.position.x - px;
    const dz = e.cat.group.position.z - pz;
    const d = Math.hypot(dx, dz);
    if (d > 2.8) continue;
    const nd = d < 0.001 ? 1 : d;
    const dot = (dx/nd)*fx + (dz/nd)*fz;
    if (dot < 0.15) continue;

    atk.hitSet.add(i);
    damageEnemy(e, 34, px, pz);
  }
}

function damageEnemy(e, dmg, sx, sz){
  if (e.dead) return;
  e.hp -= dmg;
  e.flash = 0.13;
  e.mats.forEach(function(m){ if (m.emissive) m.emissive.setHex(0x8a2020); });

  const kx = e.cat.group.position.x - sx;
  const kz = e.cat.group.position.z - sz;
  const kl = Math.hypot(kx, kz) || 1;
  e.knock.x = kx/kl * 11;
  e.knock.z = kz/kl * 11;

  if (e.hp <= 0){
    e.dead = true;
    G.score += 10;
    if (Math.random() < 0.35){
      spawnHerbAt(e.cat.group.position.x + (Math.random()-0.5)*1.5,
                  e.cat.group.position.z + (Math.random()-0.5)*1.5);
    }
  }
}

function damagePlayer(dmg){
  if (P.invuln > 0 || G.over) return;
  P.invuln = 0.45;
  G.hp -= dmg;
  flashHurt();
  if (G.hp <= 0){
    G.hp = 0;
    gameOver();
  }
}

/* ============================================================
   13. 敌人逻辑
   ============================================================ */
function updateEnemies(dt){
  for (let i = enemies.length - 1; i >= 0; i--){
    const e = enemies[i];

    if (e.dead){
      e.deadTimer += dt;
      const k = Math.max(0, 1 - e.deadTimer / 0.28);
      e.cat.group.scale.setScalar(1.1 * k);
      e.cat.group.position.y = (1 - k) * 0.6;
      e.cat.group.rotation.z += dt * 7;
      if (e.deadTimer > 0.28){
        scene.remove(e.cat.group);
        enemies.splice(i, 1);
      }
      continue;
    }

    const ep = e.cat.group.position;
    const pp = PC.group.position;
    const dx = pp.x - ep.x;
    const dz = pp.z - ep.z;
    const d  = Math.hypot(dx, dz) || 0.0001;

    if (e.knock.lengthSq() > 0.0001){
      ep.x += e.knock.x * dt;
      ep.z += e.knock.z * dt;
      e.knock.multiplyScalar(Math.exp(-7*dt));
      if (e.knock.lengthSq() < 0.02) e.knock.set(0, 0, 0);
    }

    if (d > 1.7){
      ep.x += (dx/d) * e.speed * dt;
      ep.z += (dz/d) * e.speed * dt;
      e.walkTime += dt * (e.speed / 5.8);
      e.moving = 1;
    } else {
      e.walkTime += dt * 0.3;
      e.moving = 0;
      if (e.atkCd <= 0){
        e.atkCd = 1.35;
        damagePlayer(8 + G.wave * 1.1);
      }
    }
    if (e.atkCd > 0) e.atkCd -= dt;

    resolveCollisions(ep, 0.65);

    const ty = Math.atan2(dx, dz);
    e.cat.group.rotation.y = lerpAngle(e.cat.group.rotation.y, ty, 1 - Math.exp(-10*dt));

    // 敌人播放行走动画
    playWalk(e.cat, e.walkTime, 1.0);

    if (e.flash > 0){
      e.flash -= dt;
      if (e.flash <= 0){
        e.mats.forEach(function(m){ if (m.emissive) m.emissive.setHex(0x000000); });
      }
    }
  }
}

/* ============================================================
   14. 草药
   ============================================================ */
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.7){
      scene.remove(h.group);
      herbs.splice(i, 1);
      G.hp = Math.min(G.maxHp, G.hp + 25);
      G.score += 5;
    }
  }
}

/* ============================================================
   15. 波次
   ============================================================ */
const waveTipEl = document.getElementById('waveTip');
let tipTimer = null;
function showTip(text){
  waveTipEl.textContent = text;
  waveTipEl.style.opacity = '1';
  if (tipTimer) clearTimeout(tipTimer);
  tipTimer = setTimeout(function(){ waveTipEl.style.opacity = '0'; }, 1100);
}

function startWave(){
  G.toSpawn = 2 + Math.floor(G.wave * 1.5);
  G.spawnTimer = 0.4;
  G.resting = false;
  showTip('第 ' + G.wave + ' 波');
}

function updateWaves(dt){
  if (G.toSpawn > 0){
    G.spawnTimer -= dt;
    if (G.spawnTimer <= 0 && enemies.length < 6){
      spawnEnemy();
      G.toSpawn--;
      G.spawnTimer = Math.max(0.45, 1.5 - G.wave * 0.08);
    }
  } else if (enemies.length === 0){
    if (!G.resting){
      G.resting = true;
      G.restTimer = 3.2;
      showTip('准备…');
    }
    G.restTimer -= dt;
    if (G.restTimer <= 0){
      G.wave++;
      startWave();
    }
  }
}

/* ============================================================
   16. 相机
   ============================================================ */
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);
  camera.position.lerp(_tmpDesired, 1 - Math.exp(-7*dt));
  _tmpLook.set(tp.x, tp.y + CAM_LOOK_Y, tp.z);
  camLook.lerp(_tmpLook, 1 - Math.exp(-9*dt));
  camera.lookAt(camLook);
}

/* ============================================================
   17. 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');

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   = '第 ' + G.wave + ' 波';
  if (playerAnim.attack.active || playerAnim.attack.cooldown > 0) {
    atkBtn.classList.add('cool');
  } else {
    atkBtn.classList.remove('cool');
  }
}

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';
  });
}

/* ============================================================
   18. 流程控制
   ============================================================ */
function resetGame(){
  for (let i=enemies.length-1;i>=0;i--) scene.remove(enemies[i].cat.group);
  enemies.length = 0;
  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.wave = 1;
  G.time = 0;
  G.toSpawn = 0;
  G.spawnTimer = 0;
  G.resting = false;
  G.restTimer = 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;
  startWave();
}

function gameOver(){
  G.over = true;
  G.running = false;
  attackHeld = false;
  moveInput.x = 0; moveInput.y = 0;
  knobEl.style.transform = 'translate(0px,0px)';
  finalEl.innerHTML = '你的战果<b>' + G.score + '</b>';
  overEl.classList.remove('hide');
}

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

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

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

  if (G.running){
    G.time += dt;
    updatePlayer(dt);
    checkAttackHits();
    updateEnemies(dt);
    updateHerbs(dt);
    updateWaves(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 (1571 lines, 52.6 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-mujmvdxz" to link back to the original. Then publish at arcadelab.ai/publish.