🎮ArcadeLab

OC 猫猫 · 3D 预览(重力毛流版)

by TurboKoala83
689 lines22.4 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;}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hint">拖动旋转 · 双指缩放</div>

<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 canvas = document.createElement('canvas');
  canvas.width = 512;
  canvas.height = 512;
  const ctx = canvas.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;
    const 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;
    const y = Math.random() * 512;
    const len = 2 + Math.random() * 6;
    const 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(canvas);
  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;
  const sizeMax = opts.sizeMax || 0.12;
  const embedMin = opts.embedMin || 0.75;
  const embedMax = opts.embedMax || 0.92;
  const alignToNormal = opts.alignToNormal !== undefined ? opts.alignToNormal : true;
  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;
  let safety = 0;
  const maxAttempts = count * 30;

  while (placed < count && safety < maxAttempts) {
    safety++;

    const theta = Math.random() * Math.PI * 2;
    const phi = Math.acos(2 * Math.random() - 1);

    const nx = Math.sin(phi) * Math.cos(theta);
    const ny = Math.cos(phi);     // -1 底(腹), 0 侧, 1 顶(背)
    const 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;
    const py = cy + ny * ry * embed;
    const 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);

    if (alignToNormal) {
      // 椭球外法线方向
      const nxN = nx / rx;
      const nyN = ny / ry;
      const nzN = nz / rz;
      const nlen = Math.sqrt(nxN*nxN + nyN*nyN + nzN*nzN);
      let dirX = nxN / nlen;
      let dirY = nyN / nlen;
      let 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);
      const 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);
    } else {
      fur.rotation.x = Math.random() * Math.PI * 2;
      fur.rotation.y = Math.random() * Math.PI * 2;
      fur.rotation.z = Math.random() * Math.PI * 2;
    }

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

/* ============================================================
   🦵 带关节的腿部生成函数
   ============================================================ */
function createLeg(parent, opts) {
  const x = opts.x;
  const z = opts.z;

  const upperPivot = new THREE.Group();
  upperPivot.position.set(x, 0.78, 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 paw = new THREE.Mesh(opts.pawGeo, opts.matLight);
  paw.scale.set(1.0, 0.7, 1.35);
  paw.position.set(0, -opts.lowerLen - 0.02, 0.05);
  paw.rotation.x = 0.12;
  lowerPivot.add(paw);

  return { upper: upperPivot, lower: lowerPivot };
}

/* ============================================================
   OC 猫猫建模 · 重力毛流版
   ============================================================ */
function createCat(){
  const g = new THREE.Group();

  const C_MAIN  = 0xE69A4E;
  const C_LIGHT = 0xF6DEBE;
  const C_DARK  = 0xB0682F;
  const C_LINE  = 0x3A2318;
  const 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 body = new THREE.Mesh(new THREE.SphereGeometry(0.5, 32, 24), matBody);
  body.scale.set(0.82, 0.68, 1.15);
  body.position.set(0, 0.80, 0);
  body.castShadow = true;
  g.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.72, 0.05);
  g.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.82, -0.50);
  hip.castShadow = true;
  g.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.82, 0.45);
  shoulder.castShadow = true;
  g.add(shoulder);

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

  /* ============ 身体铺毛(重力分布) ============ */
  
  // 🐾 背部主体:两侧密集,背部稀疏,腹部下垂
  addFurToEllipsoid(g, 0, 0.80, 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,    // 背部只生成 15%
    bellyDensity: 0.7,    // 腹部 70%
    bellyDroop: 0.8       // 腹部毛发下垂明显
  });

  // 🐾 肩部:两侧多一些
  addFurToEllipsoid(g, 0, 0.82, 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(g, 0, 1.05, 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 head = new THREE.Mesh(new THREE.SphereGeometry(0.38, 32, 24), matHead);
  head.scale.set(1.08, 0.92, 0.98);
  head.position.set(0, 1.25, 0.78);
  head.castShadow = true;
  g.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, 1.13, 0.92);
  g.add(chin);

  [-1, 1].forEach(function(sx){
    for (let i = 0; i < 4; i++){
      const spike = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.25, 8), matHead);
      spike.rotation.z = -sx * (Math.PI/2 + 0.1);
      spike.rotation.y =  0.15 * sx;
      spike.position.set(0.42*sx, 1.28 - i*0.07, 0.78);
      g.add(spike);
    }
  });

  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, 1.12, 1.05);
  g.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, 1.16, 1.20);
  g.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, 1.07, 1.16);
    g.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, 1.35, 1.06);
    eyeW.rotation.z = 0.40 * sx;
    g.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, 1.35, 1.09);
    eyeG.rotation.z = 0.40 * sx;
    g.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, 1.35, 1.13);
    g.add(pupil);

    const spark = new THREE.Mesh(new THREE.SphereGeometry(0.022, 8, 6), matEyeW);
    spark.position.set(0.16*sx, 1.39, 1.15);
    g.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, 1.72, 0.72);
    ear.rotation.z = -0.20 * sx;
    ear.rotation.x = -0.12;
    ear.castShadow = true;
    g.add(ear);

    const inner = new THREE.Mesh(new THREE.ConeGeometry(0.10, 0.30, 12), matDark);
    inner.position.set(0.28*sx, 1.70, 0.76);
    inner.rotation.z = -0.20 * sx;
    inner.rotation.x = -0.12;
    g.add(inner);

    const tuft = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.18, 8), matDark);
    tuft.position.set(0.28*sx, 1.99, 0.72);
    tuft.rotation.z = -0.25 * sx;
    tuft.rotation.x = -0.12;
    g.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, 1.58 - i*0.02, 0.85 - i*0.06);
      g.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(g, 0.28 * sign, 0.80, 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(g, 0.30 * sign, 0.82, -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 baseW = 0.13 + Math.random() * 0.10;
      const baseH = 0.20 + Math.random() * 0.20;
      const baseD = 0.13 + Math.random() * 0.10;

      const w = baseW * sizeMult;
      const h = baseH * sizeMult;
      const d = baseD * sizeMult;

      const furGeo = new THREE.BoxGeometry(w, h, d);
      const fur = new THREE.Mesh(furGeo, (i >= 3) ? furMatLight : furMatMain);

      const rOffset = s.r0 * (0.5 + Math.random() * 0.5);
      let yOffset = isTip ? (s.len * 0.5 + Math.random() * s.len * 0.8) : (s.len * 0.05 + Math.random() * s.len * 0.9);

      fur.position.set(Math.cos(angle) * rOffset, yOffset, Math.sin(angle) * rOffset);

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

      pivot.add(fur);
    }

    if (isTip) {
      const tipFurCount = 14;
      for (let k = 0; k < tipFurCount; k++) {
        const w = 0.12 + Math.random() * 0.10;
        const h = 0.18 + Math.random() * 0.20;
        const 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;
        const yPos = s.len - 0.06 + Math.random() * 0.16;
        
        fur.position.set(Math.cos(angle) * rOffset, yPos, 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, legs: legs, tail: tailPivots };
}

/* ================= 实例化 ================= */
const cat = createCat();
scene.add(cat.group);

/* ================= 拖动旋转 / 双指缩放 ================= */
let camDist = 6.5;
let camYaw  = Math.PI * 0.25;
let camPitch= 0.3;
let dragging = false;
let 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;
    const 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();

  cat.group.position.y = Math.sin(t * 1.4) * 0.015;

  const bases = [-0.75, 0.35, 0.35, 0.3, 0.25];
  for (let i = 0; i < cat.tail.length; i++){
    const p = cat.tail[i];
    p.rotation.x = bases[i] + Math.sin(t * 2.0 - i * 1.2) * 0.05;
    p.rotation.z = Math.sin(t * 1.6 - i * 1.4) * 0.08;
  }

  renderer.render(scene, camera);
}

renderer.setAnimationLoop(tick);

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

Game Source: OC 猫猫 · 3D 预览(重力毛流版)

Creator: TurboKoala83

Libraries: three

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