🎮ArcadeLab

OC 猫猫 · 3D 预览(攻击动画版)

by TurboKoala83
837 lines33.7 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">
<title>OC 猫猫 · 3D 预览(攻击动画版)</title>
<style>
  *{margin:0;padding:0;box-sizing:border-box;}
  html,body{width:100%;height:100%;overflow:hidden;background:#2a3240;
    font-family:-apple-system,"PingFang SC",sans-serif;
    -webkit-user-select:none;user-select:none;
    -webkit-tap-highlight-color:transparent;touch-action:none;}
  #c{display:block;width:100%;height:100%;}
  #hint{position:fixed;left:0;right:0;bottom:26px;text-align:center;
    color:rgba(255,255,255,.4);font-size:12px;letter-spacing:2px;
    pointer-events:none;}
  #modeBtn{position:fixed;top:20px;right:20px;z-index:100;
    background:rgba(255,255,255,0.15);border:1px solid rgba(255,255,255,0.3);
    color:#fff;padding:8px 16px;border-radius:20px;font-size:13px;
    font-family:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent;}
  #modeBtn:active{background:rgba(255,255,255,0.3);}
  #atkBtn{position:fixed;top:20px;left:20px;z-index:100;
    background:rgba(255,100,80,0.35);border:1px solid rgba(255,150,120,0.5);
    color:#fff;padding:8px 20px;border-radius:20px;font-size:13px;
    font-family:inherit;cursor:pointer;-webkit-tap-highlight-color:transparent;}
  #atkBtn:active{background:rgba(255,100,80,0.6);}
  #atkBtn:disabled{opacity:0.4;}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hint">拖动旋转 · 双指缩放</div>
<button id="modeBtn">▶ 行走</button>
<button id="atkBtn">⚔ 攻击</button>

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

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

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x2a3240);

const camera = new THREE.PerspectiveCamera(40, innerWidth/innerHeight, 0.1, 100);

/* ================= 灯光 ================= */
scene.add(new THREE.HemisphereLight(0xdfeaff, 0x2a2a30, 1.0));
const key = new THREE.DirectionalLight(0xfff0d8, 1.1);
key.position.set(4, 8, 5);
key.castShadow = true;
key.shadow.mapSize.set(1024, 1024);
key.shadow.camera.left = -5; key.shadow.camera.right = 5;
key.shadow.camera.top = 5; key.shadow.camera.bottom = -5;
scene.add(key);
const rim = new THREE.DirectionalLight(0x88bbff, 0.4);
rim.position.set(-5, 4, -4);
scene.add(rim);

/* ================= 地面 ================= */
const groundGeo = new THREE.CircleGeometry(6, 48);
groundGeo.rotateX(-Math.PI/2);
const ground = new THREE.Mesh(groundGeo, new THREE.MeshLambertMaterial({color:0x38424f}));
ground.position.y = -0.01;
ground.receiveShadow = true;
scene.add(ground);

/* ============================================================
   🎨 程序化毛皮纹理
   ============================================================ */
function createFurTexture() {
  const c = document.createElement('canvas');
  c.width = 512; c.height = 512;
  const ctx = c.getContext('2d');
  ctx.fillStyle = '#E69A4E'; ctx.fillRect(0, 0, 512, 512);

  ctx.strokeStyle = '#B0682F'; ctx.lineWidth = 18;
  ctx.lineCap = 'round'; ctx.lineJoin = 'round';
  for (let i = 0; i < 15; i++) {
    ctx.beginPath();
    const startY = Math.random() * 512;
    ctx.moveTo(-50, startY);
    ctx.bezierCurveTo(150, startY - 40, 350, startY + 40, 562, startY - 20);
    ctx.stroke();
  }

  ctx.strokeStyle = '#B0682F'; ctx.lineWidth = 12;
  for (let i = 0; i < 3; i++) {
    const cx = 120 + Math.random() * 270, cy = 120 + Math.random() * 270;
    const r = 30 + Math.random() * 40;
    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();
  }

  ctx.globalAlpha = 0.08;
  for (let i = 0; i < 2000; i++) {
    const x = Math.random()*512, y = Math.random()*512;
    const len = 2 + Math.random()*6, angle = Math.random()*Math.PI*2;
    ctx.strokeStyle = Math.random() > 0.5 ? '#F6DEBE' : '#8A4E2A';
    ctx.lineWidth = 1 + Math.random()*2;
    ctx.beginPath(); ctx.moveTo(x, y);
    ctx.lineTo(x + Math.cos(angle)*len, y + Math.sin(angle)*len); ctx.stroke();
  }
  ctx.globalAlpha = 1.0;

  const texture = new THREE.CanvasTexture(c);
  texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping;
  return texture;
}
const furTexture = createFurTexture();

/* ============================================================
   🐾 通用毛簇铺设
   ============================================================ */
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++;
  }
}

/* ============================================================
   🦵 腿部(含膝关节 + 脚踝枢轴)
   ============================================================ */
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, 16);
  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, 16);
  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 clawGeo = new THREE.ConeGeometry(0.018, 0.06, 6);
  const claws = [];
  for (let t = -1; t <= 1; t++) {
    const claw = new THREE.Mesh(clawGeo, matLight);
    claw.position.set(t * 0.05, -0.06, 0.12);
    claw.rotation.x = -0.5;
    claw.visible = false; // 平时收起
    pawPivot.add(claw);
    claws.push(claw);
  }

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

/* ============================================================
   OC 猫猫建模
   ============================================================ */
function createCat(){
  const g = new THREE.Group();
  const C_MAIN = 0xE69A4E, C_LIGHT = 0xF6DEBE, C_DARK = 0xB0682F,
        C_LINE = 0x3A2318, C_EYE = 0x9BF25B;

  const matBody = new THREE.MeshLambertMaterial({ map: furTexture });
  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, 32, 24), 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, 32, 24), 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, 32, 24), 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, 32, 24), 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, 20), 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, 150, 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, 60, 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, 50, 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, 32, 24), 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, 20, 16), 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, 0.0 + (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.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, 24, 20), 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, 6, 12, 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, 24, 18), 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, 24, 18), 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, 16, 12), 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, 8, 6), 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, 12), 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, 12), matDark);
    inner.position.set(0, -0.03, 0.05); ear.add(inner);
    const tuft = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.18, 8), matDark);
    tuft.position.set(0, 0.22, 0); tuft.rotation.x = 0.12; ear.add(tuft);
  });

  /* 额头 M 纹 */
  [-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, 6), 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, 16, 12);
  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, pawGeo: pawGeo });
    legs.push(leg);
    addFurToEllipsoid(bodyPivot, 0.28*sign, 0.02, 0.5, 0.13, 0.13, 0.13, 20, 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, pawGeo: pawGeo });
    legs.push(leg);
    addFurToEllipsoid(bodyPivot, 0.30*sign, 0.02, -0.68, 0.16, 0.16, 0.16, 24, 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, 16);
    geo.translate(0, s.len/2, 0);
    const mesh = new THREE.Mesh(geo, isTip ? matLight : matBody);
    mesh.castShadow = true; pivot.add(mesh);

    let furCount = 24 - i*2;
    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) {
      for (let k = 0; k < 14; 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);
  });

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

const cat = createCat();
scene.add(cat.group);

/* ============================================================
   🌟 攻击动画状态
   ============================================================ */
const attack = {
  active: false,
  timer: 0,
  duration: 0.55,   // 总时长
  // 阶段划分
  windup:   0.18,   // 蓄力
  strike:   0.28,   // 挥击(0.18 ~ 0.28)
  recover:  0.40,   // 收招(0.28 ~ 0.40)
  end:      0.55
};

// 攻击时右前腿的基准姿态(腿 index 1 = 右前腿)
const ATK_LEG = 1;

function startAttack() {
  if (attack.active) return;
  attack.active = true;
  attack.timer = 0;
  // 显示爪子
  for (let i = 0; i < cat.legs[ATK_LEG].claws.length; i++) {
    cat.legs[ATK_LEG].claws[i].visible = true;
  }
}

/* ============================================================
   🌟 攻击动画驱动
   四个阶段:蓄力 → 挥击 → 收招 → 恢复
   ============================================================ */
function driveAttackAnimation(dt) {
  if (!attack.active) return false;

  attack.timer += dt;
  const t = attack.timer;

  // 总进度 0 → 1
  const progress = Math.min(1, t / attack.duration);

  const rightLeg = cat.legs[ATK_LEG];
  const leftLeg = cat.legs[0];

  // ---- 阶段权重 ----
  // 蓄力:0 ~ 0.18
  const windup = Math.max(0, Math.min(1, t / attack.windup));
  // 挥击:0.18 ~ 0.28
  const strike = Math.max(0, Math.min(1, (t - attack.windup) / (attack.strike - attack.windup)));
  // 收招:0.28 ~ 0.40
  const recover = Math.max(0, Math.min(1, (t - attack.strike) / (attack.recover - attack.strike)));

  // 缓动函数
  const easeOut = function(x) { return 1 - Math.pow(1 - x, 3); };
  const easeIn = function(x) { return x * x * x; };

  // ============================================================
  // 身体运动
  // ============================================================
  // 蓄力时身体后仰,挥击时前倾
  let bodyPitch = 0;
  let bodyLift = 0;

  if (t < attack.windup) {
    // 蓄力:身体后仰,重心后移
    bodyPitch = -0.25 * easeOut(windup);
    bodyLift = 0.02 * windup;
  } else if (t < attack.strike) {
    // 挥击:身体猛烈前倾
    bodyPitch = -0.25 + 0.55 * easeIn(strike);
    bodyLift = 0.02 - 0.04 * strike;
  } else if (t < attack.recover) {
    // 收招:回归中立
    bodyPitch = 0.30 * (1 - easeOut(recover));
    bodyLift = -0.02 * (1 - recover);
  } else {
    // 恢复
    bodyPitch = 0;
    bodyLift = 0;
  }

  cat.bodyPivot.rotation.x = bodyPitch;
  cat.group.position.y = bodyLift;

  // ============================================================
  // 头部运动:蓄力后仰,挥击前探
  // ============================================================
  let headPitch = 0;
  if (t < attack.windup) {
    headPitch = -0.3 * easeOut(windup);
  } else if (t < attack.strike) {
    headPitch = -0.3 + 0.5 * easeIn(strike);
  } else if (t < attack.recover) {
    headPitch = 0.2 * (1 - easeOut(recover));
  } else {
    headPitch = 0;
  }
  cat.headPivot.rotation.x = headPitch;

  // ============================================================
  // 🌟 右前爪运动(核心)
  // ============================================================
  // 大腿:抬起 → 下挥
  let rightUpper = 0.12;   // 默认
  let rightLower = -0.20;  // 默认

  if (t < attack.windup) {
    // 蓄力:大腿向上抬起(负角度 = 向前上方),小腿折叠
    const w = easeOut(windup);
    rightUpper = 0.12 - 1.0 * w;    // 抬起 1.0 弧度
    rightLower = -0.20 - 1.2 * w;   // 折叠 1.2 弧度
  } else if (t < attack.strike) {
    // 挥击:大腿猛烈下挥,小腿快速伸展
    const s = easeIn(strike);
    rightUpper = -0.88 + 1.6 * s;   // 从 -0.88 挥到 +0.72
    rightLower = -1.4 + 1.5 * s;    // 从 -1.4 伸到 +0.1
  } else if (t < attack.recover) {
    // 收招:回归默认
    const r = easeOut(recover);
    const strikeEnd_upper = 0.72;
    const strikeEnd_lower = 0.10;
    rightUpper = strikeEnd_upper + (0.12 - strikeEnd_upper) * r;
    rightLower = strikeEnd_lower + (-0.20 - strikeEnd_lower) * r;
  } else {
    rightUpper = 0.12;
    rightLower = -0.20;
  }

  rightLeg.upper.rotation.x = rightUpper;
  rightLeg.lower.rotation.x = rightLower;
  rightLeg.paw.rotation.x = -(rightUpper + rightLower) * 0.85 + 0.12;

  // ============================================================
  // 左前腿:轻微配合,保持平衡
  // ============================================================
  let leftUpper = 0.12;
  let leftLower = -0.20;
  if (t < attack.windup) {
    // 蓄力时左腿微微下压
    leftUpper = 0.12 + 0.15 * windup;
  } else if (t < attack.strike) {
    // 挥击时左腿微微后撤
    leftUpper = 0.27 - 0.1 * strike;
  } else if (t < attack.recover) {
    leftUpper = 0.17 - 0.05 * recover;
  }
  leftLeg.upper.rotation.x = leftUpper;
  leftLeg.lower.rotation.x = leftLower;
  leftLeg.paw.rotation.x = -(leftUpper + leftLower) * 0.85 + 0.12;

  // ============================================================
  // 尾巴:攻击时微微上扬,增加气势
  // ============================================================
  const tailBases = [-0.75, 0.35, 0.35, 0.3, 0.25];
  let tailLift = 0;
  if (t < attack.windup) {
    tailLift = 0.3 * windup;
  } else if (t < attack.strike) {
    tailLift = 0.3 - 0.1 * strike;
  } else if (t < attack.recover) {
    tailLift = 0.2 * (1 - recover);
  }

  for (let i = 0; i < cat.tail.length; i++){
    const p = i * 0.35;
    cat.tail[i].rotation.x = tailBases[i] + tailLift * (i * 0.15);
    cat.tail[i].rotation.z = Math.sin(attack.timer * 10 - p) * 0.04 * (attack.active ? 1 : 0);
  }

  // ============================================================
  // 结束攻击
  // ============================================================
  if (t >= attack.duration) {
    attack.active = false;
    // 收起爪子
    for (let i = 0; i < rightLeg.claws.length; i++) {
      rightLeg.claws[i].visible = false;
    }
    return false;
  }

  return true;
}

/* ============================================================
   🚶 行走动画
   ============================================================ */
const walk = { speed: 0.8, stride: 0.55, bob: 0.03, sway: 0.06, tailWave: 0.08 };

function driveWalkAnimation(t){
  const phase = t * walk.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) * walk.stride;
    const lowerSwing = Math.sin(p * Math.PI * 2 - 0.6) * walk.stride * 0.6;
    const upperRot = 0.12 + upperSwing;
    const lowerRot = -0.20 + lowerSwing;
    leg.upper.rotation.x = upperRot;
    leg.lower.rotation.x = lowerRot;
    leg.paw.rotation.x = -(upperRot + lowerRot) * 0.8 + 0.12;
  }
  const bobY = Math.sin(phase * Math.PI * 4) * walk.bob;
  const swayX = Math.sin(phase * Math.PI * 2) * walk.sway;
  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) * walk.tailWave;
    cat.tail[i].rotation.z = Math.sin(phase * Math.PI * 2 - p + 1.2) * walk.tailWave * 0.7;
  }
}

/* ============================================================
   🏃 疾驰 Gallop
   ============================================================ */
const gallop = { speed: 2.0, stride: 1.0, bob: 0.12, sway: 0.03, spineFlex: 0.18, tailWave: 0.05, leap: 0.15 };

function driveGallopAnimation(t){
  const phase = t * gallop.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) * gallop.stride;
    let lowerSwing;
    if (i < 2) lowerSwing = Math.sin(p * Math.PI * 2 + 0.9) * gallop.stride * 0.9;
    else       lowerSwing = Math.sin(p * Math.PI * 2 + 1.3) * gallop.stride * 1.1;
    const upperRot = 0.12 + upperSwing;
    const lowerRot = -0.20 + lowerSwing;
    leg.upper.rotation.x = upperRot;
    leg.lower.rotation.x = lowerRot;
    leg.paw.rotation.x = -(upperRot + lowerRot) * 0.85 + 0.12;
  }
  const bobY = Math.sin(phase * Math.PI * 4) * gallop.bob;
  const leapY = Math.max(0, Math.sin(phase * Math.PI * 2)) * gallop.leap;
  cat.group.position.y = bobY + leapY;
  const swayX = Math.sin(phase * Math.PI * 2) * gallop.sway;
  cat.group.rotation.z = swayX * 0.15;
  const spineWave = Math.sin(phase * Math.PI * 2);
  cat.bodyPivot.rotation.x = spineWave * gallop.spineFlex;
  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;
  cat.headPivot.position.z = 0.78 + Math.sin(phase * Math.PI * 2) * 0.03;
  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.2 + Math.sin(phase * Math.PI * 2 - p) * gallop.tailWave;
    cat.tail[i].rotation.z = Math.sin(phase * Math.PI * 2 - p + 1.0) * gallop.tailWave * 0.5;
  }
}

/* ============================================================
   🦘 半跳跃 Bound
   ============================================================ */
const bound = { speed: 1.8, stride: 0.9, bob: 0.08, sway: 0.02, spineFlex: 0.25, tailWave: 0.04, leap: 0.2 };

function driveBoundAnimation(t){
  const phase = t * bound.speed;
  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) * bound.stride;
    let lowerSwing;
    if (isFront) lowerSwing = Math.sin(p * Math.PI * 2 + 0.5) * bound.stride * 0.8;
    else         lowerSwing = Math.sin(p * Math.PI * 2 + 1.0) * bound.stride * 1.2;
    const upperRot = 0.12 + upperSwing;
    const lowerRot = -0.20 + lowerSwing;
    leg.upper.rotation.x = upperRot;
    leg.lower.rotation.x = lowerRot;
    leg.paw.rotation.x = -(upperRot + lowerRot) * 0.85 + 0.12;
  }
  const leapY = Math.max(0, Math.sin(phase * Math.PI * 2)) * bound.leap;
  const bobY = Math.sin(phase * Math.PI * 4) * bound.bob;
  cat.group.position.y = bobY + leapY;
  const swayX = Math.sin(phase * Math.PI * 2) * bound.sway;
  cat.group.rotation.z = swayX * 0.1;
  const spineWave = Math.sin(phase * Math.PI * 2);
  cat.bodyPivot.rotation.x = spineWave * bound.spineFlex;
  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) * bound.tailWave;
    cat.tail[i].rotation.z = Math.sin(phase * Math.PI * 2 - p + 1.0) * bound.tailWave * 0.5;
  }
}

/* ============================================================
   动画模式切换
   ============================================================ */
let currentMode = 'walk';
const modeBtn = document.getElementById('modeBtn');
const modeNames = { walk: '▶ 行走', gallop: '▶ 疾驰', bound: '▶ 跳跃' };
const modeOrder = ['walk', 'gallop', 'bound'];

modeBtn.addEventListener('click', function(){
  let idx = modeOrder.indexOf(currentMode);
  idx = (idx + 1) % modeOrder.length;
  currentMode = modeOrder[idx];
  modeBtn.textContent = modeNames[currentMode];
});

/* ============================================================
   攻击按钮
   ============================================================ */
const atkBtn = document.getElementById('atkBtn');
atkBtn.addEventListener('click', function(){
  startAttack();
});

/* ============================================================
   交互控制
   ============================================================ */
let camDist = 6.5, camYaw = Math.PI * 0.25, camPitch = 0.3;
let dragging = false, lastX = 0, lastY = 0;
let pinchStart = 0, pinchDist0 = 0;

function updateCamera(){
  const cx = Math.sin(camYaw) * Math.cos(camPitch) * camDist;
  const cy = Math.sin(camPitch) * camDist;
  const cz = Math.cos(camYaw) * Math.cos(camPitch) * camDist;
  camera.position.set(cx, cy + 1.0, cz);
  camera.lookAt(0, 1.0, 0);
}
updateCamera();

canvas.addEventListener('touchstart', function(e){
  if (e.touches.length === 1){ dragging = true; lastX = e.touches[0].clientX; lastY = e.touches[0].clientY; }
  else if (e.touches.length === 2){
    dragging = false;
    pinchDist0 = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
    pinchStart = camDist;
  }
}, {passive:true});

canvas.addEventListener('touchmove', function(e){
  if (e.touches.length === 1 && dragging){
    const dx = e.touches[0].clientX - lastX, dy = e.touches[0].clientY - lastY;
    lastX = e.touches[0].clientX; lastY = e.touches[0].clientY;
    camYaw -= dx * 0.008; camPitch += dy * 0.006;
    camPitch = Math.max(-0.1, Math.min(1.1, camPitch));
    updateCamera();
  } else if (e.touches.length === 2){
    const d = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
    if (pinchDist0 > 0){ camDist = Math.max(3.5, Math.min(12, pinchStart * pinchDist0 / d)); updateCamera(); }
  }
}, {passive:true});

canvas.addEventListener('touchend', function(){ dragging = false; });
canvas.addEventListener('mousedown', function(e){ dragging = true; lastX = e.clientX; lastY = e.clientY; });
window.addEventListener('mousemove', function(e){
  if (!dragging) return;
  const dx = e.clientX - lastX, dy = e.clientY - lastY;
  lastX = e.clientX; lastY = e.clientY;
  camYaw -= dx * 0.008; camPitch += dy * 0.006;
  camPitch = Math.max(-0.1, Math.min(1.1, camPitch));
  updateCamera();
});
window.addEventListener('mouseup', function(){ dragging = false; });
canvas.addEventListener('wheel', function(e){
  e.preventDefault();
  camDist = Math.max(3.5, Math.min(12, camDist + e.deltaY * 0.004));
  updateCamera();
}, {passive:false});

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

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

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

  // 攻击动画优先
  const attacking = driveAttackAnimation(dt);

  if (!attacking) {
    // 没有攻击时,播放基础动画
    if (currentMode === 'gallop') driveGallopAnimation(t);
    else if (currentMode === 'bound') driveBoundAnimation(t);
    else driveWalkAnimation(t);
  }

  renderer.render(scene, camera);
}

renderer.setAnimationLoop(tick);

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

Game Source: OC 猫猫 · 3D 预览(攻击动画版)

Creator: TurboKoala83

Libraries: three

Complexity: complex (837 lines, 33.7 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: oc-3d-turbokoala83-mujltebt" to link back to the original. Then publish at arcadelab.ai/publish.