🎮ArcadeLab

猫武士 · 森林漫游

by TurboKoala83
902 lines28.9 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:28px;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));}
#dashBtn{width:66px;height:66px;font-size:14px;letter-spacing:1px;
  background:radial-gradient(circle at 35% 30%,rgba(120,205,255,.7),rgba(35,105,190,.6));}
.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);}
</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="stick"><div id="knob"></div></div>
  <div id="btns">
    <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 WANDERER</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" style="font-size:15px;opacity:.85;margin:6px 0 26px;">你的战果<b style="font-size:30px;color:#ffd479;display:block;">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);

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);
scene.add(sun);

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

/* ============================================================
   2. 森林场景 + 碰撞体收集
   ============================================================ */
const obstacles = [];   // { x, z, r }

(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);
    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);
    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 m = new THREE.Mesh(isRock ? rockGeo : bushGeo, isRock ? rockMat : bushMat);
    const px = Math.cos(a)*r, pz = Math.sin(a)*r;
    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;
    scene.add(m);

    if (isRock){
      obstacles.push({ x:px, z:pz, r:0.6*s });
    }
  }
})();

/* ============================================================
   3. 改良版低多边形猫猫
   ============================================================ */
function createCat(opt){
  const o = Object.assign({
    body:0xe0914a,      // 主体毛色
    belly:0xfff0dc,     // 肚皮 / 口鼻 / 爪子
    ear:0xb56b34,       // 耳内 / 斑纹
    eye:0x5cff9d,       // 眼睛
    nose:0xff9aa2,      // 鼻子
    scale:1
  }, opt || {});

  const group = new THREE.Group();
  const model = new THREE.Group();
  group.add(model);

  const mats = [];
  function lam(col){
    const m = new THREE.MeshLambertMaterial({color:col});
    mats.push(m);
    return m;
  }
  const bodyMat  = lam(o.body);
  const bellyMat = lam(o.belly);
  const earMat   = lam(o.ear);

  const eyeMat     = new THREE.MeshBasicMaterial({color:o.eye});
  const pupilMat   = new THREE.MeshBasicMaterial({color:0x1a1a20});
  const shineMat   = new THREE.MeshBasicMaterial({color:0xffffff});
  const noseMat    = new THREE.MeshBasicMaterial({color:o.nose});
  const whiskerMat = new THREE.MeshBasicMaterial({color:0xf5f5f5, transparent:true, opacity:0.8});

  /* ---------- 躯干 ---------- */
  const torso = new THREE.Mesh(new THREE.SphereGeometry(0.55, 14, 11), bodyMat);
  torso.scale.set(1, 0.9, 1.4);
  torso.position.set(0, 0.8, -0.05);
  model.add(torso);

  // 后臀
  const hip = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 9), bodyMat);
  hip.scale.set(1, 0.95, 1);
  hip.position.set(0, 0.82, -0.5);
  model.add(hip);

  // 胸口浅毛
  const chest = new THREE.Mesh(new THREE.SphereGeometry(0.42, 12, 9), bellyMat);
  chest.scale.set(0.9, 0.86, 0.88);
  chest.position.set(0, 0.6, 0.46);
  model.add(chest);

  /* ---------- 虎斑背纹 ---------- */
  const stripeGeo = new THREE.SphereGeometry(0.14, 7, 5);
  for (let i=0;i<4;i++){
    const st = new THREE.Mesh(stripeGeo, earMat);
    st.scale.set(1.5, 0.32, 0.55);
    st.position.set(0, 1.24 - i*0.015, 0.28 - i*0.26);
    model.add(st);
  }

  /* ---------- 头部 ---------- */
  const head = new THREE.Mesh(new THREE.SphereGeometry(0.42, 14, 11), bodyMat);
  head.scale.set(1.05, 0.98, 1);
  head.position.set(0, 1.2, 0.78);
  model.add(head);

  // 脸颊
  [-1, 1].forEach(function(sx){
    const cheek = new THREE.Mesh(new THREE.SphereGeometry(0.17, 9, 8), bellyMat);
    cheek.scale.set(1, 0.88, 0.9);
    cheek.position.set(0.2*sx, 1.1, 0.92);
    model.add(cheek);
  });

  // 口鼻
  const muzzle = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 8), bellyMat);
  muzzle.scale.set(1.18, 0.8, 1);
  muzzle.position.set(0, 1.08, 1.05);
  model.add(muzzle);

  // 鼻子
  const nose = new THREE.Mesh(new THREE.SphereGeometry(0.055, 7, 6), noseMat);
  nose.scale.set(1.35, 0.9, 1);
  nose.position.set(0, 1.13, 1.24);
  model.add(nose);

  // 嘴线
  const mouthMat = new THREE.MeshBasicMaterial({color:0x8a5a48});
  [-1, 1].forEach(function(sx){
    const m = new THREE.Mesh(new THREE.SphereGeometry(0.055, 6, 5), mouthMat);
    m.scale.set(0.5, 0.18, 0.5);
    m.position.set(0.06*sx, 1.03, 1.18);
    m.rotation.z = 0.55*sx;
    model.add(m);
  });

  /* ---------- 眼睛 ---------- */
  [-1, 1].forEach(function(sx){
    const eye = new THREE.Mesh(new THREE.SphereGeometry(0.088, 10, 9), eyeMat);
    eye.scale.set(1, 1.05, 0.72);
    eye.position.set(0.175*sx, 1.29, 1.02);
    model.add(eye);

    const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.042, 8, 7), pupilMat);
    pupil.scale.set(0.42, 1, 0.7);
    pupil.position.set(0.175*sx, 1.29, 1.075);
    model.add(pupil);

    const shine = new THREE.Mesh(new THREE.SphereGeometry(0.022, 6, 5), shineMat);
    shine.position.set(0.145*sx, 1.335, 1.085);
    model.add(shine);
  });

  /* ---------- 耳朵 ---------- */
  const earOuterGeo = new THREE.ConeGeometry(0.18, 0.36, 6);
  const earInnerGeo = new THREE.ConeGeometry(0.1, 0.24, 6);
  [-1, 1].forEach(function(sx){
    const ear = new THREE.Mesh(earOuterGeo, bodyMat);
    ear.position.set(0.245*sx, 1.57, 0.72);
    ear.rotation.z = -0.24*sx;
    ear.rotation.x = -0.1;
    model.add(ear);

    const inner = new THREE.Mesh(earInnerGeo, earMat);
    inner.position.set(0.24*sx, 1.545, 0.755);
    inner.rotation.z = -0.24*sx;
    inner.rotation.x = -0.1;
    model.add(inner);
  });

  /* ---------- 胡须 ---------- */
  const whiskerGeo = new THREE.CylinderGeometry(0.008, 0.003, 0.42, 3);
  [-1, 1].forEach(function(sx){
    for (let i=0;i<3;i++){
      const offset = (i - 1) * 0.26;              // -0.26 / 0 / 0.26
      const wg = new THREE.Group();
      wg.position.set(0.17*sx, 1.12, 1.06);
      wg.rotation.z = -sx * (Math.PI/2 + offset);
      const w = new THREE.Mesh(whiskerGeo, whiskerMat);
      w.position.y = 0.21;
      wg.add(w);
      model.add(wg);
    }
  });

  /* ---------- 四条腿 ---------- */
  const legGeo = new THREE.CylinderGeometry(0.105, 0.088, 0.62, 7);
  legGeo.translate(0, -0.31, 0);
  const pawGeo = new THREE.SphereGeometry(0.115, 8, 6);

  const legs = [];
  [[0.30,0.52],[-0.30,0.52],[0.32,-0.50],[-0.32,-0.50]].forEach(function(p){
    const leg = new THREE.Mesh(legGeo, bodyMat);
    leg.position.set(p[0], 0.62, p[1]);

    const paw = new THREE.Mesh(pawGeo, bellyMat);
    paw.scale.set(1, 0.68, 1.28);
    paw.position.set(0, -0.6, 0.05);
    leg.add(paw);

    // 脚趾
    for (let t=-1;t<=1;t++){
      const toe = new THREE.Mesh(new THREE.SphereGeometry(0.032, 6, 5), bellyMat);
      toe.position.set(t*0.055, -0.63, 0.15);
      leg.add(toe);
    }

    model.add(leg);
    legs.push(leg);
  });

  /* ---------- 尾巴 ---------- */
  const tail = [];
  const TAIL_N = 7;
  const tailTipGeo = new THREE.SphereGeometry(1, 8, 7);
  for (let i=0;i<TAIL_N;i++){
    const t = i/(TAIL_N-1);
    const r = 0.14 - t*0.055;
    const isTip = (i >= TAIL_N-2);
    const seg = new THREE.Mesh(tailTipGeo, isTip ? bellyMat : bodyMat);
    seg.scale.setScalar(r);
    model.add(seg);
    tail.push(seg);
  }

  /* ---------- 地面阴影 ---------- */
  const shGeo = new THREE.CircleGeometry(0.85, 18);
  shGeo.rotateX(-Math.PI/2);
  const shadow = new THREE.Mesh(shGeo, new THREE.MeshBasicMaterial({
    color:0x000000, transparent:true, opacity:0.2, depthWrite:false
  }));
  shadow.position.y = 0.02;
  group.add(shadow);

  group.scale.setScalar(o.scale);

  return {group:group, model:model, legs:legs, tail:tail, mats:mats};
}

/* ============================================================
   4. 玩家
   ============================================================ */
const PC = createCat({
  body:0xe0914a, belly:0xfff0dc, ear:0xb56b34,
  eye:0x5cff9d, nose:0xff9aa2, scale:1.15
});
PC.group.position.set(0, 0, 0);
scene.add(PC.group);

const PLAYER_RADIUS = 0.72;

const P = {
  yaw: 0, phase: 0,
  atkCd: 0, atkTimer: 0,
  dashTime: 0, dashCd: 0,
  invuln: 0
};

/* 爪击弧光 */
const slashGroup = new THREE.Group();
const slashMesh = new THREE.Mesh(
  new THREE.RingGeometry(1.0, 1.95, 18, 1, -Math.PI*0.85, Math.PI*0.7),
  new THREE.MeshBasicMaterial({color:0xffffff, transparent:true, opacity:0,
    side:THREE.DoubleSide, depthWrite:false})
);
slashMesh.rotation.x = -Math.PI/2;
slashGroup.add(slashMesh);
slashGroup.visible = false;
scene.add(slashGroup);
let slashT = 0;

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

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

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

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

/* ============================================================
   7. 输入
   ============================================================ */
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 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(); });

/* ============================================================
   8. 工具函数
   ============================================================ */
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;
}

/* ============================================================
   9. 碰撞解析(圆形推出,可沿墙滑动)
   ============================================================ */
function resolveCollisions(pos){
  // 两轮迭代,处理夹角处更稳定
  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 + PLAYER_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;
  }
}

/* ============================================================
   10. 玩家逻辑
   ============================================================ */
function updatePlayer(dt){
  let mag = Math.hypot(moveInput.x, moveInput.y);
  if (mag < 0.13) mag = 0;
  if (mag > 1) mag = 1;

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

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

  /* ---- 四足奔跑 ---- */
  const rate = dashing ? 26 : 14;
  if (mag > 0) P.phase += dt * rate * mag;
  else         P.phase += dt * 2.4;

  const amp = mag * (dashing ? 0.95 : 0.72);
  const sw = Math.sin(P.phase) * amp;
  PC.legs[0].rotation.x =  sw;
  PC.legs[1].rotation.x = -sw;
  PC.legs[2].rotation.x = -sw;
  PC.legs[3].rotation.x =  sw;

  /* ---- 身体起伏 ---- */
  const bob = mag > 0
    ? Math.abs(Math.sin(P.phase)) * 0.075 * (dashing ? 1.5 : 1)
    : Math.sin(P.phase*0.9) * 0.018;
  PC.model.position.y = bob;

  /* ---- 尾巴 ---- */
  const tailN = PC.tail.length;
  for (let i=0;i<tailN;i++){
    const p = i/(tailN-1);
    const seg = PC.tail[i];
    seg.position.set(
      Math.sin(G.time*3.2 - p*2.3) * 0.3 * p,
      0.95 + p*0.55 - p*p*0.5 + Math.sin(G.time*3)*0.025*p,
      -0.85 - p*0.62
    );
  }

  /* ---- 挥爪前扑 ---- */
  if (P.atkTimer > 0){
    P.atkTimer -= dt;
    const k = 1 - Math.max(0, P.atkTimer) / 0.22;
    PC.model.position.z = Math.sin(k * Math.PI) * 0.5;
  } else {
    PC.model.position.z = 0;
  }

  /* ---- 计时器 ---- */
  if (P.atkCd > 0)   P.atkCd -= 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 && P.atkCd <= 0) doAttack();
}

function tryDash(){
  if (!G.running) return;
  if (P.dashCd > 0 || G.stamina < 30) return;
  G.stamina -= 30;
  P.dashTime = 0.22;
  P.dashCd   = 0.85;
  P.invuln   = Math.max(P.invuln, 0.25);
}

function doAttack(){
  P.atkCd    = 0.42;
  P.atkTimer = 0.22;

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

  slashGroup.position.set(px + fx*0.9, 0.55, pz + fz*0.9);
  slashGroup.rotation.y = P.yaw;
  slashGroup.visible = true;
  slashT = 1;
}

/* ============================================================
   11. 草药更新
   ============================================================ */
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;
      G.herbsPicked++;
    }
  }
}

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

function updateCamera(dt){
  const tp = PC.group.position;

  _tmpDesired.set(tp.x, 0, 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);
}

/* ============================================================
   13. 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 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'; }, 1200);
}

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.herbsPicked;
  dashBtn.classList.toggle('cool', G.stamina < 30 || P.dashCd > 0);
}

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

/* ============================================================
   14. 流程控制
   ============================================================ */
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.atkCd = 0; P.atkTimer = 0;
  P.dashTime = 0; P.dashCd = 0; P.invuln = 0;

  PC.group.position.set(0, 0, 0);
  PC.group.rotation.y = 0;
  PC.model.position.set(0, 0, 0);

  G.hp = G.maxHp;
  G.stamina = 100;
  G.score = 0;
  G.herbsPicked = 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);

  slashGroup.visible = false;
  updateHUD();
}

function startGame(){
  resetGame();
  overlayEl.classList.add('hide');
  overEl.classList.add('hide');
  G.running = true;
  showTip('森林漫游');
}

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 style="font-size:30px;color:#ffd479;display:block;">' + G.score + '</b>';
  overEl.classList.remove('hide');
}

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

/* ============================================================
   15. 主循环
   ============================================================ */
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;
    PC.model.position.y = Math.sin(G.time * 1.5) * 0.02;
    const tn = PC.tail.length;
    for (let i=0;i<tn;i++){
      const p = i/(tn-1);
      PC.tail[i].position.set(
        Math.sin(G.time*2 - p*2.3) * 0.3 * p,
        0.95 + p*0.55 - p*p*0.5,
        -0.85 - p*0.62
      );
    }
  }

  /* 爪光衰减 */
  if (slashT > 0){
    slashT -= dt * 4.5;
    slashMesh.material.opacity = Math.max(0, slashT) * 0.85;
    const s = 1 + (1 - Math.max(0, slashT)) * 0.35;
    slashGroup.scale.set(s, 1, s);
    if (slashT <= 0) slashGroup.visible = false;
  }

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

renderer.setAnimationLoop(tick);

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

Game Source: 猫武士 · 森林漫游

Creator: TurboKoala83

Libraries: three

Complexity: complex (902 lines, 28.9 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-mujfbb66" to link back to the original. Then publish at arcadelab.ai/publish.