🎮ArcadeLab

猫武士 · 森林守卫

by TurboKoala83
981 lines30.3 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 ---------- */
#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));}
#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);}
#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="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:false, 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, 38, 105);

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. 构建森林场景
   ============================================================ */
(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 leafMat  = new THREE.MeshLambertMaterial({color:0x2f6b2a});
  const leafMat2 = 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 ? leafMat : leafMat2);
    leaf.position.set(x, 4.1*s, z);
    leaf.scale.setScalar(s);
    scene.add(leaf);
  }

  // 石头与灌木
  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 m = new THREE.Mesh(isRock ? rockGeo : bushGeo, isRock ? rockMat : bushMat);
    const s = 0.5 + Math.random()*0.9;
    m.position.set(Math.cos(a)*r, isRock ? 0.2*s : 0.45*s, Math.sin(a)*r);
    m.scale.set(s, s*0.8, s);
    m.rotation.y = Math.random()*3;
    scene.add(m);
  }
})();

/* ============================================================
   3. 低多边形猫(程序化建模)
   ============================================================ */
function createCat(opt){
  const o = Object.assign({
    body:0xd98b4a, belly:0xf5e3cb, ear:0xb8703a, eye:0x54ff9f, 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 darkMat  = new THREE.MeshBasicMaterial({color:0x2a2a32});
  const eyeMat   = new THREE.MeshBasicMaterial({color:o.eye});
  const noseMat  = new THREE.MeshBasicMaterial({color:0x3a2a26});

  /* 躯干 */
  const torso = new THREE.Mesh(new THREE.SphereGeometry(0.55, 12, 9), bodyMat);
  torso.scale.set(1, 0.85, 1.45);
  torso.position.set(0, 0.78, -0.05);
  model.add(torso);

  /* 胸口浅毛 */
  const chest = new THREE.Mesh(new THREE.SphereGeometry(0.4, 10, 8), bellyMat);
  chest.scale.set(0.9, 0.85, 0.85);
  chest.position.set(0, 0.62, 0.5);
  model.add(chest);

  /* 头 */
  const head = new THREE.Mesh(new THREE.SphereGeometry(0.42, 12, 10), bodyMat);
  head.scale.set(1, 0.95, 1);
  head.position.set(0, 1.2, 0.78);
  model.add(head);

  /* 吻部 + 鼻子 */
  const muzzle = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 8), bellyMat);
  muzzle.scale.set(1.15, 0.8, 1);
  muzzle.position.set(0, 1.08, 1.12);
  model.add(muzzle);

  const nose = new THREE.Mesh(new THREE.SphereGeometry(0.055, 6, 5), noseMat);
  nose.position.set(0, 1.12, 1.27);
  model.add(nose);

  /* 眼睛 */
  [-1, 1].forEach(function(sx){
    const eye = new THREE.Mesh(new THREE.SphereGeometry(0.078, 8, 7), eyeMat);
    eye.position.set(0.17*sx, 1.28, 1.04);
    model.add(eye);
    const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.036, 6, 5), darkMat);
    pupil.position.set(0.17*sx, 1.28, 1.11);
    model.add(pupil);
  });

  /* 耳朵 */
  const earGeo  = new THREE.ConeGeometry(0.17, 0.34, 5);
  const earGeo2 = new THREE.ConeGeometry(0.09, 0.2, 5);
  [-1, 1].forEach(function(sx){
    const ear = new THREE.Mesh(earGeo, bodyMat);
    ear.position.set(0.24*sx, 1.58, 0.72);
    ear.rotation.z = -0.22*sx;
    ear.rotation.x = -0.12;
    model.add(ear);

    const inner = new THREE.Mesh(earGeo2, earMat);
    inner.position.set(0.24*sx, 1.56, 0.765);
    inner.rotation.z = -0.22*sx;
    inner.rotation.x = -0.12;
    model.add(inner);
  });

  /* 四条腿(枢轴在髋部) */
  const legGeo = new THREE.CylinderGeometry(0.105, 0.09, 0.62, 6);
  legGeo.translate(0, -0.31, 0);
  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(new THREE.SphereGeometry(0.11, 6, 5), bellyMat);
    paw.scale.set(1, 0.7, 1.2);
    paw.position.set(0, -0.62, 0.04);
    leg.add(paw);
    model.add(leg);
    legs.push(leg);
  });

  /* 尾巴(球体链) */
  const tail = [];
  const TAIL_N = 6;
  for (let i=0;i<TAIL_N;i++){
    const r = 0.13 - i*0.012;
    const seg = new THREE.Mesh(new THREE.SphereGeometry(r, 7, 6), bodyMat);
    model.add(seg);
    tail.push(seg);
  }

  /* 地面阴影 */
  const shGeo = new THREE.CircleGeometry(0.85, 16);
  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, scale:1.15});
PC.group.position.set(0, 0, 0);
scene.add(PC.group);

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, 16, 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 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({body:0x4a3f55, belly:0x6b5f78, ear:0x2e2635, eye:0xff4a4a, scale:1.1});
  cat.group.position.set(x, 0, z);
  scene.add(cat.group);

  const hp = 60 + G.wave*14;
  enemies.push({
    group: cat.group, model: cat.model, legs: cat.legs, tail: cat.tail, mats: cat.mats,
    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,
    moving: 1
  });
}

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(){
  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; }
  spawnHerbAt(x, z);
}

/* ============================================================
   6. 游戏状态
   ============================================================ */
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:8
};

/* ============================================================
   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 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; }

  let dx = 0, dz = 0;
  if (mag > 0){
    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;
  }

  /* 世界边界 */
  const r = Math.hypot(PC.group.position.x, PC.group.position.z);
  if (r > 43){
    PC.group.position.x *= 43/r;
    PC.group.position.z *= 43/r;
  }

  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;

  for (let i = enemies.length - 1; i >= 0; i--){
    const e = enemies[i];
    if (e.dead) continue;
    const dx = e.group.position.x - px;
    const dz = e.group.position.z - pz;
    const d  = Math.hypot(dx, dz);
    if (d > 3.0) continue;
    const nd = d < 0.001 ? 1 : d;
    const dot = (dx/nd)*fx + (dz/nd)*fz;
    if (dot < 0.15) continue;
    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.group.position.x - sx;
  const kz = e.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.group.position.x + (Math.random()-0.5)*1.5,
                  e.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();
  }
}

/* ============================================================
   10. 敌人逻辑
   ============================================================ */
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.group.scale.setScalar(1.1 * k);
      e.group.position.y = (1 - k) * 0.6;
      e.group.rotation.z += dt * 7;
      if (e.deadTimer > 0.28){
        scene.remove(e.group);
        enemies.splice(i, 1);
      }
      continue;
    }

    const ep = e.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.phase += dt * 13;
      e.moving = 1;
    } else {
      e.phase += dt * 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;

    /* 朝向玩家 */
    const ty = Math.atan2(dx, dz);
    e.group.rotation.y = lerpAngle(e.group.rotation.y, ty, 1 - Math.exp(-10*dt));

    /* 腿 & 尾巴 */
    const sw = Math.sin(e.phase) * 0.7 * e.moving;
    e.legs[0].rotation.x =  sw;
    e.legs[1].rotation.x = -sw;
    e.legs[2].rotation.x = -sw;
    e.legs[3].rotation.x =  sw;

    const tn = e.tail.length;
    for (let k=0;k<tn;k++){
      const p = k/(tn-1);
      e.tail[k].position.set(
        Math.sin(G.time*3 - p*2.3) * 0.3 * p,
        0.95 + p*0.55 - p*p*0.5,
        -0.85 - p*0.62
      );
    }

    /* 受击闪红恢复 */
    if (e.flash > 0){
      e.flash -= dt;
      if (e.flash <= 0){
        e.mats.forEach(function(m){ if (m.emissive) m.emissive.setHex(0x000000); });
      }
    }
  }
}

/* ============================================================
   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;
    }
  }
}

/* ============================================================
   12. 波次系统
   ============================================================ */
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 < 7){
      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();
    }
  }
}

/* ============================================================
   13. 相机
   ============================================================ */
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);
}

/* ============================================================
   14. 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 + ' 波';
  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';
  });
}

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

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

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

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