猫武士 · 森林守卫
by TurboKoala831571 lines52.6 KB🛠️ Three.js (3D graphics)
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#0a1016">
<title>猫武士 · 森林守卫</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
html,body{
width:100%;height:100%;overflow:hidden;background:#0a1016;
font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;
-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;
touch-action:none;overscroll-behavior:none;
}
#c{position:fixed;inset:0;display:block;width:100%;height:100%;}
#hud{position:fixed;inset:0;pointer-events:none;z-index:10;}
#topLeft{position:absolute;top:calc(14px + env(safe-area-inset-top));left:14px;}
.bar{width:42vw;max-width:170px;height:13px;background:rgba(0,0,0,.5);
border-radius:8px;border:1px solid rgba(255,255,255,.28);overflow:hidden;margin-bottom:7px;}
.bar i{display:block;height:100%;width:100%;border-radius:7px;transition:width .1s linear;}
#hpFill{background:linear-gradient(90deg,#ff4d4d,#ff9a5a);}
#stFill{background:linear-gradient(90deg,#4fc3ff,#7dffc4);}
#stWrap{height:8px;width:32vw;max-width:130px;}
#topRight{position:absolute;top:calc(14px + env(safe-area-inset-top));right:16px;
text-align:right;color:#fff;text-shadow:0 2px 8px rgba(0,0,0,.7);}
#score{font-size:26px;font-weight:800;letter-spacing:1px;line-height:1;}
#waveLbl{font-size:12px;opacity:.85;margin-top:4px;letter-spacing:1px;}
#waveTip{position:absolute;top:26%;left:0;right:0;text-align:center;color:#fff;
font-size:30px;font-weight:800;letter-spacing:4px;opacity:0;transition:opacity .5s;
text-shadow:0 4px 18px rgba(0,0,0,.8);}
#stick{position:absolute;left:22px;bottom:calc(34px + env(safe-area-inset-bottom));
width:132px;height:132px;border-radius:50%;pointer-events:auto;touch-action:none;
background:radial-gradient(circle,rgba(255,255,255,.13),rgba(255,255,255,.03));
border:2px solid rgba(255,255,255,.3);
box-shadow:0 0 22px rgba(0,0,0,.35) inset;}
#knob{position:absolute;left:50%;top:50%;width:58px;height:58px;margin:-29px 0 0 -29px;
border-radius:50%;pointer-events:none;
background:radial-gradient(circle at 35% 30%,rgba(255,255,255,.92),rgba(160,210,255,.55));
box-shadow:0 4px 16px rgba(0,0,0,.45);}
#btns{position:absolute;right:20px;bottom:calc(34px + env(safe-area-inset-bottom));
display:flex;align-items:flex-end;gap:14px;pointer-events:none;}
.btn{pointer-events:auto;touch-action:none;border-radius:50%;color:#fff;
font-weight:800;display:flex;align-items:center;justify-content:center;
border:2px solid rgba(255,255,255,.38);text-shadow:0 2px 6px rgba(0,0,0,.65);
transition:transform .07s;font-family:inherit;}
.btn:active{transform:scale(.92);}
#atkBtn{width:96px;height:96px;font-size:20px;letter-spacing:2px;
background:radial-gradient(circle at 35% 30%,rgba(255,150,95,.75),rgba(190,55,40,.62));}
#jumpBtn{width:66px;height:66px;font-size:14px;letter-spacing:1px;
background:radial-gradient(circle at 35% 30%,rgba(175,255,185,.72),rgba(38,140,72,.62));}
.btn.cool{opacity:.42;}
#hurt{position:fixed;inset:0;pointer-events:none;z-index:15;opacity:0;
background:radial-gradient(circle,rgba(255,0,0,0) 35%,rgba(255,20,20,.85) 100%);}
.overlay{position:fixed;inset:0;z-index:20;display:flex;flex-direction:column;
align-items:center;justify-content:center;padding:26px;color:#fff;text-align:center;
background:radial-gradient(circle at 50% 38%,rgba(28,52,72,.9),rgba(4,9,14,.97));
backdrop-filter:blur(3px);}
.overlay.hide{display:none;}
.overlay h1{font-size:40px;letter-spacing:8px;font-weight:900;
background:linear-gradient(180deg,#fff,#8fd8ff);-webkit-background-clip:text;
background-clip:text;color:transparent;text-shadow:0 0 40px rgba(120,200,255,.35);}
.sub{font-size:12px;letter-spacing:4px;opacity:.6;margin-top:8px;}
.howto{margin:28px 0 30px;font-size:14px;line-height:2.05;opacity:.82;}
.howto p{letter-spacing:1px;}
.bigbtn{pointer-events:auto;font-family:inherit;font-size:18px;font-weight:800;letter-spacing:4px;
color:#08131c;padding:15px 46px;border:none;border-radius:40px;cursor:pointer;
background:linear-gradient(180deg,#a8ecff,#4fb8ff);
box-shadow:0 8px 26px rgba(70,180,255,.45);}
.bigbtn:active{transform:scale(.96);}
#finalScore{font-size:15px;opacity:.85;margin:6px 0 26px;letter-spacing:2px;}
#finalScore b{font-size:30px;color:#ffd479;display:block;margin-top:8px;letter-spacing:1px;}
</style>
</head>
<body>
<canvas id="c"></canvas>
<div id="hud">
<div id="topLeft">
<div class="bar"><i id="hpFill"></i></div>
<div class="bar" id="stWrap"><i id="stFill"></i></div>
</div>
<div id="topRight">
<div id="score">0</div>
<div id="waveLbl">第 1 波</div>
</div>
<div id="waveTip"></div>
<div id="stick"><div id="knob"></div></div>
<div id="btns">
<button class="btn" id="jumpBtn">跳跃</button>
<button class="btn" id="atkBtn">爪击</button>
</div>
</div>
<div id="hurt"></div>
<div class="overlay" id="overlay">
<h1>猫武士</h1>
<div class="sub">FOREST GUARDIAN</div>
<div class="howto">
<p>🐾 左侧摇杆控制移动</p>
<p>⚔️ 右下「爪击」攻击敌人</p>
<p>🦘 「跳跃」翻越障碍</p>
<p>🌿 拾取草药恢复生命</p>
<p>🛡️ 守住森林,抵御一波波入侵者</p>
</div>
<button class="bigbtn" id="startBtn">开始狩猎</button>
</div>
<div class="overlay hide" id="gameover">
<h1 style="font-size:32px;letter-spacing:6px;">力竭倒下</h1>
<div id="finalScore">你的战果<b>0</b></div>
<button class="bigbtn" id="restartBtn">再次出征</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
'use strict';
/* ============================================================
1. 渲染器 / 场景 / 相机
============================================================ */
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({canvas, antialias:true, powerPreference:'high-performance'});
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.75));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x9ccfe8);
scene.fog = new THREE.Fog(0x9ccfe8, 40, 110);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.1, 400);
const CAM_OFFSET = new THREE.Vector3(0, 5.6, 7.6);
const CAM_LOOK_Y = 1.3;
camera.position.copy(CAM_OFFSET);
camera.lookAt(0, CAM_LOOK_Y, 0);
const camLook = new THREE.Vector3(0, CAM_LOOK_Y, 0);
scene.add(new THREE.HemisphereLight(0xcfeaff, 0x3a6629, 0.95));
const sun = new THREE.DirectionalLight(0xfff2d8, 0.85);
sun.position.set(28, 46, 18);
sun.castShadow = true;
sun.shadow.mapSize.set(1024, 1024);
sun.shadow.camera.left = -20; sun.shadow.camera.right = 20;
sun.shadow.camera.top = 20; sun.shadow.camera.bottom = -20;
scene.add(sun);
window.addEventListener('resize', function(){
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* ============================================================
2. 森林场景 + 碰撞体
============================================================ */
const obstacles = [];
(function buildWorld(){
const gGeo = new THREE.CircleGeometry(92, 48);
gGeo.rotateX(-Math.PI/2);
scene.add(new THREE.Mesh(gGeo, new THREE.MeshLambertMaterial({color:0x4d7c3a})));
const patchGeo = new THREE.CircleGeometry(1, 8);
patchGeo.rotateX(-Math.PI/2);
const patchMat = new THREE.MeshLambertMaterial({color:0x416d31});
for (let i=0;i<16;i++){
const a = Math.random()*Math.PI*2, r = Math.random()*42;
const s = 2 + Math.random()*5;
const p = new THREE.Mesh(patchGeo, patchMat);
p.position.set(Math.cos(a)*r, 0.012, Math.sin(a)*r);
p.scale.set(s, 1, s);
scene.add(p);
}
const trunkGeo = new THREE.CylinderGeometry(0.22, 0.34, 2.4, 6);
const trunkMat = new THREE.MeshLambertMaterial({color:0x5a4230});
const leafGeo = new THREE.ConeGeometry(1.5, 3.6, 7);
const leafMatA = new THREE.MeshLambertMaterial({color:0x2f6b2a});
const leafMatB = new THREE.MeshLambertMaterial({color:0x3b7d33});
for (let i=0;i<52;i++){
const a = Math.random()*Math.PI*2;
const r = 10 + Math.random()*32;
const x = Math.cos(a)*r, z = Math.sin(a)*r;
const s = 0.8 + Math.random()*0.8;
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.set(x, 1.2*s, z);
trunk.scale.setScalar(s);
trunk.castShadow = true;
scene.add(trunk);
const leaf = new THREE.Mesh(leafGeo, Math.random()<0.5 ? leafMatA : leafMatB);
leaf.position.set(x, 4.1*s, z);
leaf.scale.setScalar(s);
leaf.castShadow = true;
scene.add(leaf);
obstacles.push({ x:x, z:z, r:0.62*s });
}
const rockGeo = new THREE.IcosahedronGeometry(0.6, 0);
const rockMat = new THREE.MeshLambertMaterial({color:0x7b7f83});
const bushGeo = new THREE.SphereGeometry(0.7, 7, 6);
const bushMat = new THREE.MeshLambertMaterial({color:0x35702c});
for (let i=0;i<26;i++){
const a = Math.random()*Math.PI*2, r = 8 + Math.random()*36;
const isRock = Math.random() < 0.45;
const s = 0.5 + Math.random()*0.9;
const px = Math.cos(a)*r, pz = Math.sin(a)*r;
const m = new THREE.Mesh(isRock ? rockGeo : bushGeo, isRock ? rockMat : bushMat);
m.position.set(px, isRock ? 0.2*s : 0.45*s, pz);
m.scale.set(s, s*0.8, s);
m.rotation.y = Math.random()*3;
m.castShadow = true;
scene.add(m);
if (isRock) obstacles.push({ x:px, z:pz, r:0.6*s });
}
})();
function insideObstacle(x, z, pad){
const p = pad || 0;
for (let i=0;i<obstacles.length;i++){
const o = obstacles[i];
const dx = x - o.x, dz = z - o.z;
const rr = o.r + p;
if (dx*dx + dz*dz < rr*rr) return true;
}
return false;
}
/* ============================================================
3. 毛皮纹理 + 毛簇函数
============================================================ */
function createFurTexture(baseColor, stripeColor) {
const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.fillStyle = baseColor; ctx.fillRect(0, 0, 256, 256);
ctx.strokeStyle = stripeColor; ctx.lineWidth = 10;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
for (let i = 0; i < 8; i++) {
ctx.beginPath();
const startY = Math.random() * 256;
ctx.moveTo(-25, startY);
ctx.bezierCurveTo(75, startY - 20, 175, startY + 20, 281, startY - 10);
ctx.stroke();
}
ctx.strokeStyle = stripeColor; ctx.lineWidth = 7;
for (let i = 0; i < 2; i++) {
const cx = 60 + Math.random()*135, cy = 60 + Math.random()*135;
const r = 15 + Math.random()*20;
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 1.5); ctx.stroke();
ctx.beginPath(); ctx.arc(cx + r*0.3, cy - r*0.3, r*0.4, 0, Math.PI*2); ctx.stroke();
}
const texture = new THREE.CanvasTexture(canvas);
texture.wrapS = THREE.RepeatWrapping; texture.wrapT = THREE.RepeatWrapping;
return texture;
}
const furTexOrange = createFurTexture('#E69A4E', '#B0682F');
const furTexDark = createFurTexture('#5a4a62', '#2f2638');
function addFurToEllipsoid(parent, cx, cy, cz, rx, ry, rz, count, matMain, matDark, opts) {
opts = opts || {};
const sizeMin = opts.sizeMin || 0.06, sizeMax = opts.sizeMax || 0.12;
const embedMin = opts.embedMin || 0.75, embedMax = opts.embedMax || 0.92;
const bellyDroop = opts.bellyDroop !== undefined ? opts.bellyDroop : 0.5;
const backDensity = opts.backDensity !== undefined ? opts.backDensity : 0.15;
const bellyDensity = opts.bellyDensity !== undefined ? opts.bellyDensity : 0.7;
let placed = 0, safety = 0;
while (placed < count && safety < count * 30) {
safety++;
const theta = Math.random()*Math.PI*2, phi = Math.acos(2*Math.random()-1);
const nx = Math.sin(phi)*Math.cos(theta), ny = Math.cos(phi), nz = Math.sin(phi)*Math.sin(theta);
let probability;
if (ny > 0.6) probability = backDensity;
else if (ny > -0.3) probability = 1.0;
else probability = bellyDensity;
if (Math.random() > probability) continue;
const embed = embedMin + Math.random()*(embedMax-embedMin);
const px = cx + nx*rx*embed, py = cy + ny*ry*embed, pz = cz + nz*rz*embed;
const w = sizeMin + Math.random()*(sizeMax-sizeMin);
const h = sizeMin + Math.random()*(sizeMax-sizeMin);
const d = sizeMin + Math.random()*(sizeMax-sizeMin);
const mat = Math.random() < 0.15 ? matDark : matMain;
const fur = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), mat);
fur.position.set(px, py, pz);
const nxN = nx/rx, nyN = ny/ry, nzN = nz/rz;
const nlen = Math.sqrt(nxN*nxN + nyN*nyN + nzN*nzN);
let dirX = nxN/nlen, dirY = nyN/nlen, dirZ = nzN/nlen;
if (ny < -0.2) {
dirY -= bellyDroop*0.6*Math.abs(ny);
const l2 = Math.sqrt(dirX*dirX + dirY*dirY + dirZ*dirZ);
dirX/=l2; dirY/=l2; dirZ/=l2;
}
const up = new THREE.Vector3(0,1,0), dir = new THREE.Vector3(dirX,dirY,dirZ);
const quat = new THREE.Quaternion().setFromUnitVectors(up, dir);
fur.quaternion.copy(quat);
fur.rotateX((Math.random()-0.5)*1.2);
fur.rotateZ((Math.random()-0.5)*1.2);
parent.add(fur); placed++;
}
}
/* ============================================================
4. 腿部构建
============================================================ */
function createLeg(parent, opts) {
const upperPivot = new THREE.Group();
upperPivot.position.set(opts.x, 0.78, opts.z);
upperPivot.rotation.x = opts.tiltUpper;
parent.add(upperPivot);
const upperGeo = new THREE.CylinderGeometry(opts.upperR1, opts.upperR0, opts.upperLen, 12);
upperGeo.translate(0, -opts.upperLen/2, 0);
const upper = new THREE.Mesh(upperGeo, opts.matBody);
upper.castShadow = true;
upperPivot.add(upper);
const lowerPivot = new THREE.Group();
lowerPivot.position.y = -opts.upperLen;
lowerPivot.rotation.x = opts.tiltLower;
upperPivot.add(lowerPivot);
const lowerGeo = new THREE.CylinderGeometry(opts.lowerR1, opts.lowerR0, opts.lowerLen, 12);
lowerGeo.translate(0, -opts.lowerLen/2, 0);
const lower = new THREE.Mesh(lowerGeo, opts.matBody);
lower.castShadow = true;
lowerPivot.add(lower);
const pawPivot = new THREE.Group();
pawPivot.position.set(0, -opts.lowerLen, 0);
lowerPivot.add(pawPivot);
const paw = new THREE.Mesh(opts.pawGeo, opts.matLight);
paw.scale.set(1.0, 0.7, 1.35);
paw.position.set(0, -0.02, 0.05);
paw.rotation.x = 0.12;
pawPivot.add(paw);
const clawPivot = new THREE.Group();
clawPivot.position.set(0, -0.03, 0.13);
pawPivot.add(clawPivot);
for (let t = -1; t <= 1; t++) {
const clawGeo = new THREE.ConeGeometry(0.016, 0.085, 4);
const claw = new THREE.Mesh(clawGeo, opts.matDark);
claw.position.set(t * 0.042, 0, 0);
claw.rotation.x = Math.PI / 2 + 0.3;
claw.rotation.z = t * 0.12;
clawPivot.add(claw);
}
clawPivot.visible = false;
return { upper: upperPivot, lower: lowerPivot, paw: pawPivot, claw: clawPivot };
}
/* ============================================================
5. 猫模型(可传配色和毛发密度)
============================================================ */
function createCat(opts){
opts = opts || {};
const C_MAIN = opts.main !== undefined ? opts.main : 0xE69A4E;
const C_LIGHT = opts.light !== undefined ? opts.light : 0xF6DEBE;
const C_DARK = opts.dark !== undefined ? opts.dark : 0xB0682F;
const C_EYE = opts.eye !== undefined ? opts.eye : 0x9BF25B;
const FUR_TEXTURE = opts.texture !== undefined ? opts.texture : furTexOrange;
const DENSITY = opts.density !== undefined ? opts.density : 1.0;
const SCALE = opts.scale !== undefined ? opts.scale : 1.0;
const g = new THREE.Group();
const C_LINE = 0x3A2318;
const matBody = new THREE.MeshLambertMaterial({ map: FUR_TEXTURE });
const matMain = new THREE.MeshLambertMaterial({color: C_MAIN});
const matLight = new THREE.MeshLambertMaterial({color: C_LIGHT});
const matDark = new THREE.MeshLambertMaterial({color: C_DARK});
const matLine = new THREE.MeshLambertMaterial({color: C_LINE});
const matEyeW = new THREE.MeshBasicMaterial({color: 0xFFFFFF});
const matEyeG = new THREE.MeshBasicMaterial({color: C_EYE});
const matEyeB = new THREE.MeshBasicMaterial({color: 0x101014});
const bodyPivot = new THREE.Group();
bodyPivot.position.set(0, 0.80, 0);
g.add(bodyPivot);
const body = new THREE.Mesh(new THREE.SphereGeometry(0.5, 20, 16), matBody);
body.scale.set(0.82, 0.68, 1.15);
body.castShadow = true;
bodyPivot.add(body);
const belly = new THREE.Mesh(new THREE.SphereGeometry(0.42, 16, 12), matLight);
belly.scale.set(0.80, 0.72, 1.05);
belly.position.set(0, -0.08, 0.05);
bodyPivot.add(belly);
const hip = new THREE.Mesh(new THREE.SphereGeometry(0.42, 16, 12), matBody);
hip.scale.set(0.85, 0.75, 0.90);
hip.position.set(0, 0.02, -0.50);
hip.castShadow = true;
bodyPivot.add(hip);
const shoulder = new THREE.Mesh(new THREE.SphereGeometry(0.38, 16, 12), matBody);
shoulder.scale.set(0.85, 0.80, 0.95);
shoulder.position.set(0, 0.02, 0.45);
shoulder.castShadow = true;
bodyPivot.add(shoulder);
const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.32, 0.35, 12), matBody);
neck.position.set(0, 0.25, 0.42);
neck.rotation.x = -0.32;
neck.castShadow = true;
bodyPivot.add(neck);
addFurToEllipsoid(bodyPivot, 0, 0, 0, 0.41, 0.34, 0.575, Math.round(100*DENSITY), matMain, matDark, {
sizeMin: 0.14, sizeMax: 0.26, embedMin: 0.72, embedMax: 0.88,
backDensity: 0.15, bellyDensity: 0.7, bellyDroop: 0.8 });
addFurToEllipsoid(bodyPivot, 0, 0.02, 0.45, 0.323, 0.304, 0.361, Math.round(40*DENSITY), matMain, matDark, {
sizeMin: 0.12, sizeMax: 0.20, embedMin: 0.72, embedMax: 0.88,
backDensity: 0.3, bellyDensity: 0.6, bellyDroop: 0.6 });
addFurToEllipsoid(bodyPivot, 0, 0.25, 0.42, 0.28, 0.20, 0.28, Math.round(30*DENSITY), matMain, matDark, {
sizeMin: 0.10, sizeMax: 0.18, embedMin: 0.72, embedMax: 0.88,
backDensity: 0.4, bellyDensity: 0.6, bellyDroop: 0.5 });
const matHead = new THREE.MeshLambertMaterial({color: C_MAIN});
const headPivot = new THREE.Group();
headPivot.position.set(0, 0.45, 0.78);
bodyPivot.add(headPivot);
const head = new THREE.Mesh(new THREE.SphereGeometry(0.38, 20, 16), matHead);
head.scale.set(1.08, 0.92, 0.98);
head.castShadow = true;
headPivot.add(head);
const chin = new THREE.Mesh(new THREE.SphereGeometry(0.24, 12, 10), matHead);
chin.scale.set(0.85, 0.65, 0.90);
chin.position.set(0, -0.12, 0.14);
headPivot.add(chin);
[-1, 1].forEach(function(sx){
const baseY = 0.03, spacingY = 0.065;
for (let i = 0; i < 4; i++){
const w = 0.12+Math.random()*0.04, h = 0.15+Math.random()*0.04, d = 0.11+Math.random()*0.04;
const spike = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), matHead);
spike.position.set(0.32*sx, baseY - i*spacingY, (Math.random()-0.5)*0.05);
spike.rotation.z = -sx*0.2;
spike.rotation.y = sx*0.5;
spike.rotation.x = (Math.random()-0.5)*0.4;
headPivot.add(spike);
}
const bottomSpike = new THREE.Mesh(new THREE.BoxGeometry(0.13, 0.16, 0.12), matHead);
bottomSpike.position.set(0.32*sx, baseY - 4*spacingY, 0);
bottomSpike.rotation.z = -sx*0.15;
bottomSpike.rotation.y = sx*0.4;
headPivot.add(bottomSpike);
});
const muzzle = new THREE.Mesh(new THREE.SphereGeometry(0.19, 14, 12), matLight);
muzzle.scale.set(1.20, 0.75, 1.0);
muzzle.position.set(0, -0.13, 0.27);
headPivot.add(muzzle);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.055, 0.065, 4), matLine);
nose.rotation.x = Math.PI; nose.rotation.y = Math.PI/4;
nose.position.set(0, -0.09, 0.42);
headPivot.add(nose);
const mouthGeo = new THREE.TorusGeometry(0.06, 0.008, 4, 8, Math.PI);
[-1, 1].forEach(function(sx){
const m = new THREE.Mesh(mouthGeo, matLine);
m.rotation.z = Math.PI/2; m.rotation.y = Math.PI;
m.position.set(0.055*sx, -0.18, 0.38);
headPivot.add(m);
});
[-1, 1].forEach(function(sx){
const eyeW = new THREE.Mesh(new THREE.SphereGeometry(0.115, 14, 10), matEyeW);
eyeW.scale.set(1.0, 0.75, 0.45);
eyeW.position.set(0.19*sx, 0.10, 0.28);
eyeW.rotation.z = 0.40*sx;
headPivot.add(eyeW);
const eyeG = new THREE.Mesh(new THREE.SphereGeometry(0.092, 12, 10), matEyeG);
eyeG.scale.set(0.92, 0.80, 0.45);
eyeG.position.set(0.19*sx, 0.10, 0.31);
eyeG.rotation.z = 0.40*sx;
headPivot.add(eyeG);
const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.042, 8, 6), matEyeB);
pupil.scale.set(0.40, 1.1, 0.5);
pupil.position.set(0.19*sx, 0.10, 0.35);
headPivot.add(pupil);
const spark = new THREE.Mesh(new THREE.SphereGeometry(0.022, 6, 5), matEyeW);
spark.position.set(0.16*sx, 0.14, 0.37);
headPivot.add(spark);
});
[-1, 1].forEach(function(sx){
const ear = new THREE.Mesh(new THREE.ConeGeometry(0.18, 0.50, 8), matHead);
ear.position.set(0.28*sx, 0.47, -0.06);
ear.rotation.z = -0.20*sx;
ear.rotation.x = -0.12;
ear.castShadow = true;
headPivot.add(ear);
const inner = new THREE.Mesh(new THREE.ConeGeometry(0.10, 0.30, 8), matDark);
inner.position.set(0, -0.03, 0.05);
ear.add(inner);
const tuft = new THREE.Mesh(new THREE.ConeGeometry(0.05, 0.18, 6), matDark);
tuft.position.set(0, 0.22, 0);
tuft.rotation.x = 0.12;
ear.add(tuft);
});
[-1, 1].forEach(function(sx){
for (let i = 0; i < 3; i++){
const s = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.16, 4), matDark);
s.rotation.x = Math.PI/2;
s.rotation.z = -0.35*sx;
s.position.set(0.09*sx, 0.33 - i*0.02, 0.07 - i*0.06);
headPivot.add(s);
}
});
const pawGeo = new THREE.SphereGeometry(0.105, 10, 8);
const legs = [];
[1, -1].forEach(function(sign){
const leg = createLeg(g, {
x: 0.28*sign, z: 0.5,
upperR0: 0.105, upperR1: 0.09, upperLen: 0.32,
lowerR0: 0.085, lowerR1: 0.072, lowerLen: 0.36,
tiltUpper: 0.12, tiltLower: -0.20,
matBody: matBody, matLight: matLight, matDark: matDark, pawGeo: pawGeo
});
legs.push(leg);
addFurToEllipsoid(bodyPivot, 0.28*sign, 0.02, 0.5, 0.13, 0.13, 0.13, Math.round(15*DENSITY), matMain, matDark, {
sizeMin: 0.09, sizeMax: 0.16, embedMin: 0.70, embedMax: 0.88,
backDensity: 0.2, bellyDensity: 0.7, bellyDroop: 0.6 });
});
[1, -1].forEach(function(sign){
const leg = createLeg(g, {
x: 0.30*sign, z: -0.68,
upperR0: 0.16, upperR1: 0.13, upperLen: 0.36,
lowerR0: 0.095, lowerR1: 0.075, lowerLen: 0.34,
tiltUpper: 0.15, tiltLower: -0.28,
matBody: matBody, matLight: matLight, matDark: matDark, pawGeo: pawGeo
});
legs.push(leg);
addFurToEllipsoid(bodyPivot, 0.30*sign, 0.02, -0.68, 0.16, 0.16, 0.16, Math.round(18*DENSITY), matMain, matDark, {
sizeMin: 0.10, sizeMax: 0.18, embedMin: 0.70, embedMax: 0.88,
backDensity: 0.2, bellyDensity: 0.7, bellyDroop: 0.6 });
});
const tailSegs = [
{ len: 0.30, r0: 0.100, r1: 0.088, ang: -0.75 },
{ len: 0.32, r0: 0.088, r1: 0.075, ang: 0.35 },
{ len: 0.32, r0: 0.075, r1: 0.062, ang: 0.35 },
{ len: 0.30, r0: 0.062, r1: 0.050, ang: 0.3 },
{ len: 0.28, r0: 0.050, r1: 0.038, ang: 0.25 }
];
const tailRoot = new THREE.Group();
tailRoot.position.set(0, 0.85, -0.55);
g.add(tailRoot);
const tailPivots = [];
let attach = tailRoot;
tailSegs.forEach(function(s, i){
const pivot = new THREE.Group();
if (i > 0) pivot.position.y = tailSegs[i-1].len;
pivot.rotation.x = s.ang;
const isTip = (i === tailSegs.length - 1);
const geo = new THREE.CylinderGeometry(s.r1, s.r0, s.len, 10);
geo.translate(0, s.len/2, 0);
const mesh = new THREE.Mesh(geo, isTip ? matLight : matBody);
mesh.castShadow = true;
pivot.add(mesh);
let furCount = Math.round((24 - i*2) * DENSITY);
const furMatMain = new THREE.MeshLambertMaterial({color: C_MAIN});
const furMatLight = new THREE.MeshLambertMaterial({color: C_LIGHT});
for (let j = 0; j < furCount; j++) {
const angle = (j/furCount)*Math.PI*2 + (Math.random()-0.5)*0.6;
const sizeMult = isTip ? 0.75 : (1.0 - (i/tailSegs.length)*0.25);
const w = (0.13+Math.random()*0.10)*sizeMult;
const h = (0.20+Math.random()*0.20)*sizeMult;
const d = (0.13+Math.random()*0.10)*sizeMult;
const fur = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), (i >= 3) ? furMatLight : furMatMain);
const rOffset = s.r0 * (0.5 + Math.random()*0.5);
const yOffset = isTip ? (s.len*0.5 + Math.random()*s.len*0.8) : (s.len*0.05 + Math.random()*s.len*0.9);
fur.position.set(Math.cos(angle)*rOffset, yOffset, Math.sin(angle)*rOffset);
const tilt = isTip ? (0.15 + Math.random()*0.25) : (0.25 + Math.random()*0.4);
fur.rotation.z = -Math.cos(angle)*tilt;
fur.rotation.x = Math.sin(angle)*tilt;
fur.rotation.y = (Math.random()-0.5)*1.0;
pivot.add(fur);
}
if (isTip) {
const tipFurCount = Math.round(10 * DENSITY);
for (let k = 0; k < tipFurCount; k++) {
const w = 0.12+Math.random()*0.10, h = 0.18+Math.random()*0.20, d = 0.12+Math.random()*0.10;
const fur = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), furMatLight);
const rOffset = s.r1 * (0.3 + Math.random()*0.9);
const angle = Math.random()*Math.PI*2;
fur.position.set(Math.cos(angle)*rOffset, s.len - 0.06 + Math.random()*0.16, Math.sin(angle)*rOffset);
fur.rotation.z = (Math.random()-0.5)*1.6;
fur.rotation.x = (Math.random()-0.5)*1.6;
fur.rotation.y = Math.random()*Math.PI*2;
pivot.add(fur);
}
const centerFur = new THREE.Mesh(new THREE.BoxGeometry(0.15, 0.28, 0.15), furMatLight);
centerFur.position.set(0, s.len + 0.07, 0);
centerFur.rotation.set(0.3, 0.5, -0.2);
pivot.add(centerFur);
}
attach.add(pivot);
attach = pivot;
tailPivots.push(pivot);
});
g.scale.setScalar(SCALE);
return { group: g, bodyPivot, headPivot, legs, tail: tailPivots };
}
/* ============================================================
6. 玩家实例
============================================================ */
const PLAYER_RADIUS = 0.72;
const PC = createCat({
main: 0xE69A4E, light: 0xF6DEBE, dark: 0xB0682F, eye: 0x9BF25B,
texture: furTexOrange, density: 0.7, scale: 1.15
});
PC.group.position.set(0, 0, 0);
scene.add(PC.group);
const P = {
yaw: 0, phase: 0,
vy: 0, onGround: true,
dashTime: 0, dashCd: 0,
invuln: 0,
walkTime: 0
};
/* 玩家动画状态 */
const playerAnim = {
mode: 'idle', // idle / walk / gallop / bound / attack
attack: {
active: false,
timer: 0,
duration: 0.75,
cooldown: 0,
cooldownMax: 0.35,
hitSet: new Set()
}
};
/* 攻击时的四肢参考:legs[0]=左前, [1]=右前, [2]=左后, [3]=右后 */
const LEG_RF = 1, LEG_LF = 0, LEG_LH = 2, LEG_RH = 3;
/* ============================================================
7. 敌人 / 草药
============================================================ */
const enemies = [];
const herbs = [];
function spawnEnemy(){
const a = Math.random()*Math.PI*2;
const d = 17 + Math.random()*8;
let x = PC.group.position.x + Math.cos(a)*d;
let z = PC.group.position.z + Math.sin(a)*d;
const rad = Math.hypot(x, z);
if (rad > 44){ x *= 44/rad; z *= 44/rad; }
const cat = createCat({
main: 0x5a4a62, light: 0x7a6a82, dark: 0x2f2638, eye: 0xff4a4a,
texture: furTexDark, density: 0.35, scale: 1.1
});
cat.group.position.set(x, 0, z);
scene.add(cat.group);
const hp = 60 + G.wave*14;
enemies.push({
cat: cat,
hp: hp, maxHp: hp,
speed: 3.0 + G.wave*0.16,
atkCd: 0.8 + Math.random()*0.8,
phase: Math.random()*6,
knock: new THREE.Vector3(),
flash: 0,
dead: false,
deadTimer: 0,
yaw: 0,
walkTime: 0,
moving: 1,
mats: [cat.bodyPivot.children[0].material]
});
}
function spawnHerbAt(x, z){
const g = new THREE.Group();
const bulb = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.3, 0),
new THREE.MeshLambertMaterial({color:0x6dffa0, emissive:0x1c5c32})
);
bulb.position.y = 0.55;
g.add(bulb);
const stem = new THREE.Mesh(
new THREE.CylinderGeometry(0.05, 0.07, 0.5, 5),
new THREE.MeshLambertMaterial({color:0x2f7a3f})
);
stem.position.y = 0.25;
g.add(stem);
g.position.set(x, 0, z);
scene.add(g);
herbs.push({group:g, bulb:bulb, t:Math.random()*6});
}
function spawnHerbNearPlayer(){
for (let tries = 0; tries < 14; tries++){
const a = Math.random()*Math.PI*2;
const r = 8 + Math.random()*12;
let x = PC.group.position.x + Math.cos(a)*r;
let z = PC.group.position.z + Math.sin(a)*r;
const rad = Math.hypot(x, z);
if (rad > 40){ x *= 40/rad; z *= 40/rad; }
if (!insideObstacle(x, z, 1.0)){
spawnHerbAt(x, z);
return;
}
}
}
/* ============================================================
8. 游戏状态
============================================================ */
const G = {
running: false, over: false,
hp: 100, maxHp: 100,
stamina: 100,
score: 0,
wave: 1,
time: 0,
toSpawn: 0, spawnTimer: 0,
resting: false, restTimer: 0,
herbTimer: 6
};
/* ============================================================
9. 输入
============================================================ */
const moveInput = {x:0, y:0};
const stickEl = document.getElementById('stick');
const knobEl = document.getElementById('knob');
const STICK_R = 52;
let stickPointer = null;
function stickUpdate(e){
const r = stickEl.getBoundingClientRect();
const cx = r.left + r.width/2, cy = r.top + r.height/2;
let dx = e.clientX - cx, dy = e.clientY - cy;
const len = Math.hypot(dx, dy);
if (len > STICK_R){ dx = dx/len*STICK_R; dy = dy/len*STICK_R; }
knobEl.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
if (len < 8){ moveInput.x = 0; moveInput.y = 0; }
else { moveInput.x = dx/STICK_R; moveInput.y = dy/STICK_R; }
}
stickEl.addEventListener('pointerdown', function(e){
e.preventDefault();
if (stickPointer !== null) return;
stickPointer = e.pointerId;
try{ stickEl.setPointerCapture(e.pointerId); }catch(err){}
stickUpdate(e);
});
stickEl.addEventListener('pointermove', function(e){
if (e.pointerId !== stickPointer) return;
e.preventDefault();
stickUpdate(e);
});
function stickEnd(e){
if (e.pointerId !== stickPointer) return;
stickPointer = null;
moveInput.x = 0; moveInput.y = 0;
knobEl.style.transform = 'translate(0px,0px)';
}
stickEl.addEventListener('pointerup', stickEnd);
stickEl.addEventListener('pointercancel', stickEnd);
const atkBtn = document.getElementById('atkBtn');
let attackHeld = false;
atkBtn.addEventListener('pointerdown', function(e){
e.preventDefault(); attackHeld = true;
try{ atkBtn.setPointerCapture(e.pointerId); }catch(err){}
});
atkBtn.addEventListener('pointerup', function(){ attackHeld = false; });
atkBtn.addEventListener('pointercancel', function(){ attackHeld = false; });
const jumpBtn = document.getElementById('jumpBtn');
jumpBtn.addEventListener('pointerdown', function(e){
e.preventDefault();
tryJump();
});
document.addEventListener('touchmove', function(e){ e.preventDefault(); }, {passive:false});
document.addEventListener('gesturestart', function(e){ e.preventDefault(); });
/* ============================================================
10. 工具函数
============================================================ */
function lerpAngle(a, b, t){
let d = (b - a) % (Math.PI*2);
if (d > Math.PI) d -= Math.PI*2;
if (d < -Math.PI) d += Math.PI*2;
return a + d*t;
}
function resolveCollisions(pos, radius){
for (let pass=0; pass<2; pass++){
for (let i=0;i<obstacles.length;i++){
const o = obstacles[i];
const dx = pos.x - o.x;
const dz = pos.z - o.z;
const minD = o.r + radius;
const d2 = dx*dx + dz*dz;
if (d2 < minD*minD){
const d = Math.sqrt(d2) || 0.0001;
const push = (minD - d) / d;
pos.x += dx * push;
pos.z += dz * push;
}
}
}
const rad = Math.hypot(pos.x, pos.z);
if (rad > 43){
pos.x *= 43/rad;
pos.z *= 43/rad;
}
}
/* ============================================================
11. 动画驱动函数
============================================================ */
function compensatePaw(leg, upperRot, lowerRot, factor) {
const f = factor !== undefined ? factor : 0.8;
leg.paw.rotation.x = -(upperRot + lowerRot) * f + 0.12;
}
/* ---- 行走 ---- */
function playWalk(cat, t, speedScale){
const speed = 0.8 * (speedScale || 1);
const phase = t * speed;
const phases = [0.75, 0.25, 0.5, 0.0];
for (let i = 0; i < cat.legs.length; i++){
const leg = cat.legs[i];
const p = (phase + phases[i]) % 1.0;
const upperSwing = Math.sin(p * Math.PI * 2) * 0.55;
const lowerSwing = Math.sin(p * Math.PI * 2 - 0.6) * 0.33;
const upperRot = 0.12 + upperSwing;
const lowerRot = -0.20 + lowerSwing;
leg.upper.rotation.x = upperRot;
leg.lower.rotation.x = lowerRot;
compensatePaw(leg, upperRot, lowerRot, 0.8);
}
const bobY = Math.sin(phase * Math.PI * 4) * 0.03;
const swayX = Math.sin(phase * Math.PI * 2) * 0.06;
cat.group.position.y = bobY;
cat.group.rotation.z = swayX * 0.3;
cat.bodyPivot.rotation.y = Math.sin(phase * Math.PI * 2) * 0.04;
cat.bodyPivot.rotation.x = Math.sin(phase * Math.PI * 4 + 1.0) * 0.02;
cat.headPivot.rotation.x = -cat.bodyPivot.rotation.x * 0.5;
cat.headPivot.rotation.z = -swayX * 0.5;
const tailBases = [-0.75, 0.35, 0.35, 0.3, 0.25];
for (let i = 0; i < cat.tail.length; i++){
const p = i * 0.35;
cat.tail[i].rotation.x = tailBases[i] + Math.sin(phase * Math.PI * 2 - p) * 0.08;
cat.tail[i].rotation.z = Math.sin(phase * Math.PI * 2 - p + 1.2) * 0.056;
}
}
/* ---- 疾驰 ---- */
function playGallop(cat, t){
const phase = t * 2.0;
const phases = [0.75, 0.25, 0.5, 0.0];
for (let i = 0; i < cat.legs.length; i++){
const leg = cat.legs[i];
const p = (phase + phases[i]) % 1.0;
const upperSwing = Math.sin(p * Math.PI * 2) * 1.0;
let lowerSwing;
if (i < 2) lowerSwing = Math.sin(p * Math.PI * 2 + 0.9) * 0.9;
else lowerSwing = Math.sin(p * Math.PI * 2 + 1.3) * 1.1;
const upperRot = 0.12 + upperSwing;
const lowerRot = -0.20 + lowerSwing;
leg.upper.rotation.x = upperRot;
leg.lower.rotation.x = lowerRot;
compensatePaw(leg, upperRot, lowerRot, 0.85);
}
const bobY = Math.sin(phase * Math.PI * 4) * 0.12;
const leapY = Math.max(0, Math.sin(phase * Math.PI * 2)) * 0.15;
cat.group.position.y = bobY + leapY;
const swayX = Math.sin(phase * Math.PI * 2) * 0.03;
cat.group.rotation.z = swayX * 0.15;
const spineWave = Math.sin(phase * Math.PI * 2);
cat.bodyPivot.rotation.x = spineWave * 0.18;
cat.bodyPivot.rotation.y = Math.sin(phase * Math.PI * 2 + 0.5) * 0.04;
cat.headPivot.rotation.x = -cat.bodyPivot.rotation.x * 0.7;
cat.headPivot.rotation.z = -swayX * 0.3;
const tailBases = [-0.75, 0.35, 0.35, 0.3, 0.25];
for (let i = 0; i < cat.tail.length; i++){
const p = i * 0.3;
const runBase = tailBases[i] - 0.2;
cat.tail[i].rotation.x = runBase + Math.sin(phase * Math.PI * 2 - p) * 0.05;
cat.tail[i].rotation.z = Math.sin(phase * Math.PI * 2 - p + 1.0) * 0.025;
}
}
/* ---- 半跳跃(跳跃姿态) ---- */
function playBound(cat, t){
const phase = t * 1.8;
for (let i = 0; i < cat.legs.length; i++){
const leg = cat.legs[i];
const isFront = (i < 2);
const p = (phase + (isFront ? 0.0 : 0.5)) % 1.0;
const upperSwing = Math.sin(p * Math.PI * 2) * 0.9;
let lowerSwing;
if (isFront) lowerSwing = Math.sin(p * Math.PI * 2 + 0.5) * 0.72;
else lowerSwing = Math.sin(p * Math.PI * 2 + 1.0) * 1.08;
const upperRot = 0.12 + upperSwing;
const lowerRot = -0.20 + lowerSwing;
leg.upper.rotation.x = upperRot;
leg.lower.rotation.x = lowerRot;
compensatePaw(leg, upperRot, lowerRot, 0.85);
}
const leapY = Math.max(0, Math.sin(phase * Math.PI * 2)) * 0.2;
const bobY = Math.sin(phase * Math.PI * 4) * 0.08;
cat.group.position.y = bobY + leapY;
const swayX = Math.sin(phase * Math.PI * 2) * 0.02;
cat.group.rotation.z = swayX * 0.1;
const spineWave = Math.sin(phase * Math.PI * 2);
cat.bodyPivot.rotation.x = spineWave * 0.25;
cat.headPivot.rotation.x = -cat.bodyPivot.rotation.x * 0.8;
cat.headPivot.rotation.z = -swayX * 0.2;
const tailBases = [-0.75, 0.35, 0.35, 0.3, 0.25];
for (let i = 0; i < cat.tail.length; i++){
const p = i * 0.3;
cat.tail[i].rotation.x = tailBases[i] - 0.15 + Math.sin(phase * Math.PI * 2 - p) * 0.04;
cat.tail[i].rotation.z = Math.sin(phase * Math.PI * 2 - p + 1.0) * 0.02;
}
}
/* ---- 待机(轻微呼吸) ---- */
function playIdle(cat, t){
const breathe = Math.sin(t * 1.4) * 0.5 + 0.5;
for (let i = 0; i < cat.legs.length; i++){
const leg = cat.legs[i];
leg.upper.rotation.x = 0.12;
leg.lower.rotation.x = -0.20;
compensatePaw(leg, 0.12, -0.20, 0.8);
}
cat.group.position.y = Math.sin(t * 1.4) * 0.015;
cat.group.rotation.z = 0;
cat.bodyPivot.rotation.x = 0;
cat.bodyPivot.rotation.y = 0;
cat.headPivot.rotation.x = 0;
cat.headPivot.rotation.z = 0;
const tailBases = [-0.75, 0.35, 0.35, 0.3, 0.25];
for (let i = 0; i < cat.tail.length; i++){
const p = i * 0.35;
cat.tail[i].rotation.x = tailBases[i] + Math.sin(t * 2.0 - p) * 0.06;
cat.tail[i].rotation.z = Math.sin(t * 1.6 - p + 1.2) * 0.05;
}
}
/* ---- 攻击(全身接管,右前爪大幅挥击) ---- */
function playAttack(cat, p){
const RF_BASE = { u: 0.12, l: -0.20, p: 0.12 };
const HIND_BASE = { u: 0.15, l: -0.28, p: 0.12 };
let rfU, rfL, rfP, clawOpen;
let lfU, lfL, lfP;
let hindU, hindL, hindP;
let bodyPitch, bodyYaw, bodyRoll, bodyY, bodyZ;
let headPitch, headYaw;
if (p < 0.4) {
const k = p / 0.4;
const ease = Math.sin(k * Math.PI * 0.5);
rfU = RF_BASE.u - 1.6 * ease;
rfL = RF_BASE.l - 1.15 * ease;
rfP = RF_BASE.p - 0.85 * ease;
clawOpen = 0.85 * ease;
lfU = 0.12 + 0.15 * ease;
lfL = -0.20 - 0.10 * ease;
lfP = 0.12 - 0.05 * ease;
hindU = HIND_BASE.u + 0.45 * ease;
hindL = HIND_BASE.l - 0.55 * ease;
hindP = HIND_BASE.p + 0.15 * ease;
bodyPitch = 0.22 * ease;
bodyYaw = 0.25 * ease;
bodyRoll = -0.10 * ease;
bodyY = -0.02 * ease;
bodyZ = -0.05 * ease;
headPitch = 0.22 * ease;
headYaw = 0.15 * ease;
} else if (p < 0.65) {
const k = (p - 0.4) / 0.25;
const ease = k * k * (3 - 2 * k);
const rfUStart = RF_BASE.u - 1.6;
const rfLEnd = RF_BASE.l - 1.15;
const rfPEnd = RF_BASE.p - 0.85;
rfU = rfUStart + (2.9 * ease);
rfL = rfLEnd + (1.95 * ease);
rfP = rfPEnd + (1.55 * ease);
clawOpen = 1.0;
lfU = 0.27 - 0.05 * ease;
lfL = -0.30 + 0.05 * ease;
lfP = 0.07;
hindU = 0.60 - 0.25 * ease;
hindL = -0.83 + 0.25 * ease;
hindP = 0.27 - 0.05 * ease;
bodyPitch = 0.22 - 0.42 * ease;
bodyYaw = 0.25 - 0.40 * ease;
bodyRoll = -0.10 + 0.15 * ease;
bodyY = -0.02 + 0.04 * ease;
bodyZ = -0.05 + 0.12 * ease;
headPitch = 0.22 - 0.20 * ease;
headYaw = 0.15 - 0.22 * ease;
} else {
const k = (p - 0.65) / 0.35;
const ease = Math.sin(k * Math.PI * 0.5);
const rfUEnd = RF_BASE.u - 1.6 + 2.9;
const rfLEnd = RF_BASE.l - 1.15 + 1.95;
const rfPEnd = RF_BASE.p - 0.85 + 1.55;
rfU = rfUEnd + (RF_BASE.u - rfUEnd) * ease;
rfL = rfLEnd + (RF_BASE.l - rfLEnd) * ease;
rfP = rfPEnd + (RF_BASE.p - rfPEnd) * ease;
clawOpen = 1.0 - ease;
lfU = 0.12 + 0.10 * (1 - ease);
lfL = -0.20 - 0.05 * (1 - ease);
lfP = 0.12;
hindU = 0.15 + 0.20 * (1 - ease);
hindL = -0.28 - 0.30 * (1 - ease);
hindP = 0.12;
bodyPitch = -0.20 * (1 - ease);
bodyYaw = -0.15 * (1 - ease);
bodyRoll = 0.05 * (1 - ease);
bodyY = 0.02 * (1 - ease);
bodyZ = 0.07 * (1 - ease);
headPitch = 0.02 * (1 - ease);
headYaw = -0.07 * (1 - ease);
}
const legRF = cat.legs[LEG_RF];
legRF.upper.rotation.x = rfU;
legRF.lower.rotation.x = rfL;
legRF.paw.rotation.x = rfP;
legRF.claw.visible = clawOpen > 0.05;
legRF.claw.rotation.x = -clawOpen * 0.6;
legRF.claw.children.forEach(function(claw, idx){
claw.rotation.z = (idx - 1) * 0.12 * (1 + clawOpen * 0.8);
});
const legLF = cat.legs[LEG_LF];
legLF.upper.rotation.x = lfU;
legLF.lower.rotation.x = lfL;
legLF.paw.rotation.x = lfP;
const legLH = cat.legs[LEG_LH];
const legRH = cat.legs[LEG_RH];
[legLH, legRH].forEach(function(leg){
leg.upper.rotation.x = hindU;
leg.lower.rotation.x = hindL;
leg.paw.rotation.x = hindP;
});
cat.bodyPivot.rotation.x = bodyPitch;
cat.bodyPivot.rotation.y = bodyYaw;
cat.bodyPivot.rotation.z = bodyRoll;
cat.bodyPivot.position.y = 0.80 + bodyY;
cat.bodyPivot.position.z = bodyZ;
cat.headPivot.rotation.x = headPitch;
cat.headPivot.rotation.y = headYaw;
const tailBases = [-0.75, 0.35, 0.35, 0.3, 0.25];
const tailSwing = bodyYaw * 1.5;
for (let i = 0; i < cat.tail.length; i++){
const pi = i * 0.3;
cat.tail[i].rotation.x = tailBases[i] + Math.sin(p * 30 - pi) * 0.08;
cat.tail[i].rotation.z = tailSwing * (1 + pi * 0.5);
}
}
/* ============================================================
12. 玩家逻辑
============================================================ */
const GRAVITY = -22;
const JUMP_VELOCITY = 8.6;
function tryJump(){
if (!G.running || !P.onGround) return;
P.vy = JUMP_VELOCITY;
P.onGround = false;
}
function triggerAttack(){
if (playerAnim.attack.active || playerAnim.attack.cooldown > 0) return;
playerAnim.attack.active = true;
playerAnim.attack.timer = 0;
playerAnim.attack.hitSet.clear();
}
function updatePlayer(dt){
let mag = Math.hypot(moveInput.x, moveInput.y);
if (mag < 0.13) mag = 0;
if (mag > 1) mag = 1;
// 攻击时锁定移动
if (playerAnim.attack.active) mag = 0;
let speed = 5.8;
const dashing = P.dashTime > 0;
if (dashing){ speed = 12.5; P.dashTime -= dt; }
if (mag > 0){
let dx = moveInput.x, dz = moveInput.y;
const l = Math.hypot(dx, dz) || 1;
dx /= l; dz /= l;
const targetYaw = Math.atan2(dx, dz);
P.yaw = lerpAngle(P.yaw, targetYaw, 1 - Math.exp(-18*dt));
const sp = speed * mag * dt;
PC.group.position.x += dx * sp;
PC.group.position.z += dz * sp;
}
resolveCollisions(PC.group.position, PLAYER_RADIUS);
/* 重力 */
if (!P.onGround){
P.vy += GRAVITY * dt;
PC.group.position.y += P.vy * dt;
if (PC.group.position.y <= 0){
PC.group.position.y = 0;
P.vy = 0;
P.onGround = true;
}
}
/* 朝向 */
PC.group.rotation.y = P.yaw;
/* ==== 动画选择 ==== */
let currentSpeed = 0;
if (mag > 0) currentSpeed = speed * mag;
if (playerAnim.attack.active){
const p = Math.min(1, playerAnim.attack.timer / playerAnim.attack.duration);
playAttack(PC, p);
playerAnim.attack.timer += dt;
if (p >= 1){
playerAnim.attack.active = false;
playerAnim.attack.cooldown = playerAnim.attack.cooldownMax;
// 恢复身体姿态
PC.bodyPivot.position.y = 0.80;
PC.bodyPivot.position.z = 0;
PC.bodyPivot.rotation.z = 0;
PC.headPivot.rotation.y = 0;
const rf = PC.legs[LEG_RF];
rf.claw.visible = false;
rf.claw.rotation.x = 0;
}
} else if (!P.onGround){
// 空中用 bound 动画
P.walkTime += dt;
playBound(PC, P.walkTime);
} else if (currentSpeed > 8){
// 冲刺 / 高速:疾驰
P.walkTime += dt;
playGallop(PC, P.walkTime);
} else if (currentSpeed > 0.5){
// 普通移动:行走
P.walkTime += dt * (currentSpeed / 5.8);
playWalk(PC, P.walkTime, 1.0);
} else {
// 待机
playIdle(PC, G.time);
}
/* 计时器 */
if (playerAnim.attack.cooldown > 0) playerAnim.attack.cooldown -= dt;
if (P.dashCd > 0) P.dashCd -= dt;
if (P.invuln > 0) P.invuln -= dt;
if (G.stamina < 100) G.stamina = Math.min(100, G.stamina + 24*dt);
/* 自动连击 */
if (attackHeld && !playerAnim.attack.active && playerAnim.attack.cooldown <= 0) triggerAttack();
}
/* 攻击命中判定 */
function checkAttackHits(){
const atk = playerAnim.attack;
if (!atk.active) return;
const p = atk.timer / atk.duration;
// 只在挥击阶段判定
if (p < 0.4 || p > 0.65) return;
const px = PC.group.position.x, pz = PC.group.position.z;
const fx = Math.sin(P.yaw), fz = Math.cos(P.yaw);
for (let i = enemies.length - 1; i >= 0; i--){
const e = enemies[i];
if (e.dead) continue;
if (atk.hitSet.has(i)) continue;
const dx = e.cat.group.position.x - px;
const dz = e.cat.group.position.z - pz;
const d = Math.hypot(dx, dz);
if (d > 2.8) continue;
const nd = d < 0.001 ? 1 : d;
const dot = (dx/nd)*fx + (dz/nd)*fz;
if (dot < 0.15) continue;
atk.hitSet.add(i);
damageEnemy(e, 34, px, pz);
}
}
function damageEnemy(e, dmg, sx, sz){
if (e.dead) return;
e.hp -= dmg;
e.flash = 0.13;
e.mats.forEach(function(m){ if (m.emissive) m.emissive.setHex(0x8a2020); });
const kx = e.cat.group.position.x - sx;
const kz = e.cat.group.position.z - sz;
const kl = Math.hypot(kx, kz) || 1;
e.knock.x = kx/kl * 11;
e.knock.z = kz/kl * 11;
if (e.hp <= 0){
e.dead = true;
G.score += 10;
if (Math.random() < 0.35){
spawnHerbAt(e.cat.group.position.x + (Math.random()-0.5)*1.5,
e.cat.group.position.z + (Math.random()-0.5)*1.5);
}
}
}
function damagePlayer(dmg){
if (P.invuln > 0 || G.over) return;
P.invuln = 0.45;
G.hp -= dmg;
flashHurt();
if (G.hp <= 0){
G.hp = 0;
gameOver();
}
}
/* ============================================================
13. 敌人逻辑
============================================================ */
function updateEnemies(dt){
for (let i = enemies.length - 1; i >= 0; i--){
const e = enemies[i];
if (e.dead){
e.deadTimer += dt;
const k = Math.max(0, 1 - e.deadTimer / 0.28);
e.cat.group.scale.setScalar(1.1 * k);
e.cat.group.position.y = (1 - k) * 0.6;
e.cat.group.rotation.z += dt * 7;
if (e.deadTimer > 0.28){
scene.remove(e.cat.group);
enemies.splice(i, 1);
}
continue;
}
const ep = e.cat.group.position;
const pp = PC.group.position;
const dx = pp.x - ep.x;
const dz = pp.z - ep.z;
const d = Math.hypot(dx, dz) || 0.0001;
if (e.knock.lengthSq() > 0.0001){
ep.x += e.knock.x * dt;
ep.z += e.knock.z * dt;
e.knock.multiplyScalar(Math.exp(-7*dt));
if (e.knock.lengthSq() < 0.02) e.knock.set(0, 0, 0);
}
if (d > 1.7){
ep.x += (dx/d) * e.speed * dt;
ep.z += (dz/d) * e.speed * dt;
e.walkTime += dt * (e.speed / 5.8);
e.moving = 1;
} else {
e.walkTime += dt * 0.3;
e.moving = 0;
if (e.atkCd <= 0){
e.atkCd = 1.35;
damagePlayer(8 + G.wave * 1.1);
}
}
if (e.atkCd > 0) e.atkCd -= dt;
resolveCollisions(ep, 0.65);
const ty = Math.atan2(dx, dz);
e.cat.group.rotation.y = lerpAngle(e.cat.group.rotation.y, ty, 1 - Math.exp(-10*dt));
// 敌人播放行走动画
playWalk(e.cat, e.walkTime, 1.0);
if (e.flash > 0){
e.flash -= dt;
if (e.flash <= 0){
e.mats.forEach(function(m){ if (m.emissive) m.emissive.setHex(0x000000); });
}
}
}
}
/* ============================================================
14. 草药
============================================================ */
function updateHerbs(dt){
G.herbTimer -= dt;
if (G.herbTimer <= 0 && herbs.length < 4){
G.herbTimer = 9 + Math.random()*7;
spawnHerbNearPlayer();
}
for (let i = herbs.length - 1; i >= 0; i--){
const h = herbs[i];
h.t += dt;
h.bulb.rotation.y += dt * 2;
h.bulb.position.y = 0.55 + Math.sin(h.t * 2.5) * 0.1;
const dx = h.group.position.x - PC.group.position.x;
const dz = h.group.position.z - PC.group.position.z;
if (Math.hypot(dx, dz) < 1.7){
scene.remove(h.group);
herbs.splice(i, 1);
G.hp = Math.min(G.maxHp, G.hp + 25);
G.score += 5;
}
}
}
/* ============================================================
15. 波次
============================================================ */
const waveTipEl = document.getElementById('waveTip');
let tipTimer = null;
function showTip(text){
waveTipEl.textContent = text;
waveTipEl.style.opacity = '1';
if (tipTimer) clearTimeout(tipTimer);
tipTimer = setTimeout(function(){ waveTipEl.style.opacity = '0'; }, 1100);
}
function startWave(){
G.toSpawn = 2 + Math.floor(G.wave * 1.5);
G.spawnTimer = 0.4;
G.resting = false;
showTip('第 ' + G.wave + ' 波');
}
function updateWaves(dt){
if (G.toSpawn > 0){
G.spawnTimer -= dt;
if (G.spawnTimer <= 0 && enemies.length < 6){
spawnEnemy();
G.toSpawn--;
G.spawnTimer = Math.max(0.45, 1.5 - G.wave * 0.08);
}
} else if (enemies.length === 0){
if (!G.resting){
G.resting = true;
G.restTimer = 3.2;
showTip('准备…');
}
G.restTimer -= dt;
if (G.restTimer <= 0){
G.wave++;
startWave();
}
}
}
/* ============================================================
16. 相机
============================================================ */
const _tmpDesired = new THREE.Vector3();
const _tmpLook = new THREE.Vector3();
function updateCamera(dt){
const tp = PC.group.position;
_tmpDesired.set(tp.x, tp.y * 0.55, tp.z).add(CAM_OFFSET);
camera.position.lerp(_tmpDesired, 1 - Math.exp(-7*dt));
_tmpLook.set(tp.x, tp.y + CAM_LOOK_Y, tp.z);
camLook.lerp(_tmpLook, 1 - Math.exp(-9*dt));
camera.lookAt(camLook);
}
/* ============================================================
17. HUD
============================================================ */
const hpFillEl = document.getElementById('hpFill');
const stFillEl = document.getElementById('stFill');
const scoreEl = document.getElementById('score');
const waveEl = document.getElementById('waveLbl');
const hurtEl = document.getElementById('hurt');
const overlayEl = document.getElementById('overlay');
const overEl = document.getElementById('gameover');
const finalEl = document.getElementById('finalScore');
function updateHUD(){
hpFillEl.style.width = Math.max(0, G.hp / G.maxHp * 100) + '%';
stFillEl.style.width = Math.max(0, G.stamina) + '%';
scoreEl.textContent = G.score;
waveEl.textContent = '第 ' + G.wave + ' 波';
if (playerAnim.attack.active || playerAnim.attack.cooldown > 0) {
atkBtn.classList.add('cool');
} else {
atkBtn.classList.remove('cool');
}
}
function flashHurt(){
hurtEl.style.transition = 'none';
hurtEl.style.opacity = '0.9';
requestAnimationFrame(function(){
hurtEl.style.transition = 'opacity 0.42s ease-out';
hurtEl.style.opacity = '0';
});
}
/* ============================================================
18. 流程控制
============================================================ */
function resetGame(){
for (let i=enemies.length-1;i>=0;i--) scene.remove(enemies[i].cat.group);
enemies.length = 0;
for (let i=herbs.length-1;i>=0;i--) scene.remove(herbs[i].group);
herbs.length = 0;
P.yaw = 0; P.phase = 0;
P.vy = 0; P.onGround = true;
P.dashTime = 0; P.dashCd = 0; P.invuln = 0;
P.walkTime = 0;
playerAnim.attack.active = false;
playerAnim.attack.timer = 0;
playerAnim.attack.cooldown = 0;
playerAnim.attack.hitSet.clear();
PC.group.position.set(0, 0, 0);
PC.group.rotation.y = 0;
PC.bodyPivot.position.set(0, 0.80, 0);
PC.bodyPivot.rotation.set(0, 0, 0);
PC.headPivot.rotation.set(0, 0, 0);
G.hp = G.maxHp;
G.stamina = 100;
G.score = 0;
G.wave = 1;
G.time = 0;
G.toSpawn = 0;
G.spawnTimer = 0;
G.resting = false;
G.restTimer = 0;
G.herbTimer = 6;
G.over = false;
camera.position.set(0, CAM_OFFSET.y, CAM_OFFSET.z);
camLook.set(0, CAM_LOOK_Y, 0);
camera.lookAt(camLook);
updateHUD();
}
function startGame(){
resetGame();
overlayEl.classList.add('hide');
overEl.classList.add('hide');
G.running = true;
startWave();
}
function gameOver(){
G.over = true;
G.running = false;
attackHeld = false;
moveInput.x = 0; moveInput.y = 0;
knobEl.style.transform = 'translate(0px,0px)';
finalEl.innerHTML = '你的战果<b>' + G.score + '</b>';
overEl.classList.remove('hide');
}
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('restartBtn').addEventListener('click', startGame);
/* ============================================================
19. 主循环
============================================================ */
const clock = new THREE.Clock();
function tick(){
const dt = Math.min(clock.getDelta(), 0.05);
if (G.running){
G.time += dt;
updatePlayer(dt);
checkAttackHits();
updateEnemies(dt);
updateHerbs(dt);
updateWaves(dt);
updateHUD();
} else {
// 待机
G.time += dt * 0.6;
playIdle(PC, G.time);
}
updateCamera(dt);
renderer.render(scene, camera);
}
renderer.setAnimationLoop(tick);
})();
</script>
</body>
</html>Game Source: 猫武士 · 森林守卫
Creator: TurboKoala83
Libraries: three
Complexity: complex (1571 lines, 52.6 KB)
The full source code is displayed above on this page.
Remix Instructions
To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: game-turbokoala83-mujmvdxz" to link back to the original. Then publish at arcadelab.ai/publish.