猫武士 · 自由探索
by TurboKoala831800 lines63.2 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:12px;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:90px;height:90px;font-size:19px;letter-spacing:2px;
background:radial-gradient(circle at 35% 30%,rgba(255,150,95,.75),rgba(190,55,40,.62));}
#jumpBtn{width:62px;height:62px;font-size:13px;letter-spacing:1px;
background:radial-gradient(circle at 35% 30%,rgba(175,255,185,.72),rgba(38,140,72,.62));}
#dashBtn{width:62px;height:62px;font-size:13px;letter-spacing:1px;
background:radial-gradient(circle at 35% 30%,rgba(140,200,255,.78),rgba(40,90,180,.62));}
.btn.cool{opacity:.4;}
#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%);}
#minimap{position:absolute;top:calc(14px + env(safe-area-inset-top));right:16px;
width:110px;height:110px;background:rgba(0,0,0,.4);border-radius:8px;
border:1px solid rgba(255,255,255,.2);overflow:hidden;}
#minimap canvas{width:100%;height:100%;display:block;}
.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">自由探索</div>
</div>
<div id="waveTip"></div>
<div id="minimap"><canvas id="minimapCanvas" width="110" height="110"></canvas></div>
<div id="stick"><div id="knob"></div></div>
<div id="btns">
<button class="btn" id="jumpBtn">跳跃</button>
<button class="btn" id="dashBtn">冲刺</button>
<button class="btn" id="atkBtn">爪击</button>
</div>
</div>
<div id="hurt"></div>
<div class="overlay" id="overlay">
<h1>猫武士</h1>
<div class="sub">FOREST GUARDIAN</div>
<div class="howto">
<p>🐾 左侧摇杆控制移动</p>
<p>⚔️ 右下「爪击」攻击</p>
<p>💨 「冲刺」四拍疾驰,快速位移</p>
<p>🦘 「跳跃」翻越障碍</p>
<p>🌿 拾取草药恢复生命</p>
</div>
<button class="bigbtn" id="startBtn">进入小镇</button>
</div>
<div class="overlay hide" id="gameover">
<h1 style="font-size:32px;letter-spacing:6px;">探索结束</h1>
<div id="finalScore">你的战果<b>0</b></div>
<button class="bigbtn" id="restartBtn">再次出征</button>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
(function(){
'use strict';
/* ============================================================
1. 渲染器 / 场景 / 相机
============================================================ */
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({canvas, antialias: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(0xa8c8e0);
scene.fog = new THREE.Fog(0xa8c8e0, 30, 65);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth/window.innerHeight, 0.05, 200);
// 🌟 相机缩近:猫变小了,相机要贴近
const CAM_OFFSET = new THREE.Vector3(0, 2.6, 4.5);
const CAM_LOOK_Y = 0.35;
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(0xdfeaff, 0x3a4a29, 0.95));
const sun = new THREE.DirectionalLight(0xfff2d8, 0.9);
sun.position.set(30, 45, 25);
sun.castShadow = true;
sun.shadow.mapSize.set(2048, 2048);
sun.shadow.camera.left = -50; sun.shadow.camera.right = 50;
sun.shadow.camera.top = 50; sun.shadow.camera.bottom = -50;
sun.shadow.camera.near = 1; sun.shadow.camera.far = 150;
scene.add(sun);
window.addEventListener('resize', function(){
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* ============================================================
2. 小镇地图(保持现有尺寸,猫变小)
猫的原始模型高约 1.7 单位,缩放 0.42 后约 0.7 单位
房子高 5~8 单位 —— 猫的 7~11 倍高
============================================================ */
const WORLD_HALF = 38;
const circleObstacles = [];
const rectObstacles = [];
(function buildTown(){
const groundGeo = new THREE.PlaneGeometry(WORLD_HALF*2, WORLD_HALF*2);
groundGeo.rotateX(-Math.PI/2);
scene.add(new THREE.Mesh(groundGeo, new THREE.MeshLambertMaterial({color:0x5a7d3a})));
const patchGeo = new THREE.CircleGeometry(1, 8);
patchGeo.rotateX(-Math.PI/2);
for (let i=0;i<50;i++){
const a = Math.random()*Math.PI*2, r = Math.random()*36;
const s = 1 + Math.random()*3;
const col = Math.random() < 0.5 ? 0x517238 : 0x608840;
const p = new THREE.Mesh(patchGeo, new THREE.MeshLambertMaterial({color:col}));
p.position.set(Math.cos(a)*r, 0.01, Math.sin(a)*r);
p.scale.set(s, 1, s);
scene.add(p);
}
const roadMat = new THREE.MeshLambertMaterial({color:0x554e42});
const roadEdgeMat = new THREE.MeshLambertMaterial({color:0x6b6253});
const mainRoadW = 6;
const roadH = new THREE.Mesh(new THREE.PlaneGeometry(WORLD_HALF*2, mainRoadW), roadMat);
roadH.rotation.x = -Math.PI/2;
roadH.position.y = 0.02;
scene.add(roadH);
const roadV = new THREE.Mesh(new THREE.PlaneGeometry(mainRoadW, WORLD_HALF*2), roadMat);
roadV.rotation.x = -Math.PI/2;
roadV.position.y = 0.021;
scene.add(roadV);
function addEdge(x, z, w, d){
const e = new THREE.Mesh(new THREE.PlaneGeometry(w, d), roadEdgeMat);
e.rotation.x = -Math.PI/2;
e.position.set(x, 0.025, z);
scene.add(e);
}
addEdge(0, mainRoadW/2, WORLD_HALF*2, 0.15);
addEdge(0, -mainRoadW/2, WORLD_HALF*2, 0.15);
addEdge( mainRoadW/2, 0, 0.15, WORLD_HALF*2);
addEdge(-mainRoadW/2, 0, 0.15, WORLD_HALF*2);
const ringR = 22;
const ringW = 4;
const segs = [
{ x:0, z:-ringR, w:ringR*2, d:ringW },
{ x:0, z: ringR, w:ringR*2, d:ringW },
{ x:-ringR, z:0, w:ringW, d:ringR*2 },
{ x: ringR, z:0, w:ringW, d:ringR*2 }
];
segs.forEach(function(s){
const r = new THREE.Mesh(new THREE.PlaneGeometry(s.w, s.d), roadMat);
r.rotation.x = -Math.PI/2;
r.position.set(s.x, 0.018, s.z);
scene.add(r);
});
const plazaGeo = new THREE.CircleGeometry(mainRoadW*0.75, 16);
plazaGeo.rotateX(-Math.PI/2);
const plaza = new THREE.Mesh(plazaGeo, new THREE.MeshLambertMaterial({color:0x7a6e5c}));
plaza.position.y = 0.03;
scene.add(plaza);
/* ---------- 建筑物 ---------- */
const buildingColors = [
0x8a6f4a, 0xa0805a, 0x6e5a42, 0x9a7a55, 0x7a6250,
0x8a7a5a, 0x6a5a45, 0xb08a60
];
const roofColors = [0x5a3a2a, 0x6a4a30, 0x4a3020, 0x7a5a3a, 0x3a2a1a];
function makeBuilding(cx, cz, w, d, h, colIdx){
const g = new THREE.Group();
const bodyMat = new THREE.MeshLambertMaterial({color: buildingColors[colIdx % buildingColors.length]});
const body = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), bodyMat);
body.position.y = h/2;
body.castShadow = true;
body.receiveShadow = true;
g.add(body);
const roofMat = new THREE.MeshLambertMaterial({color: roofColors[colIdx % roofColors.length]});
const roof = new THREE.Mesh(
new THREE.ConeGeometry(Math.max(w, d)*0.72, h*0.55, 4),
roofMat
);
roof.position.y = h + h*0.27;
roof.rotation.y = Math.PI/4;
roof.castShadow = true;
g.add(roof);
const winMat = new THREE.MeshBasicMaterial({color:0xffe8a0});
const winCount = Math.floor(w * d / 6) + 1;
for (let i=0;i<winCount;i++){
const wx = (Math.random()-0.5) * (w-1.5);
const wy = 1.2 + Math.random() * (h-2);
const wz = (Math.random() < 0.5 ? 1 : -1) * d/2;
const win = new THREE.Mesh(new THREE.PlaneGeometry(0.8, 1.1), winMat);
win.position.set(wx, wy, wz*1.01);
if (wz < 0) win.rotation.y = Math.PI;
g.add(win);
}
g.position.set(cx, 0, cz);
scene.add(g);
rectObstacles.push({ cx: cx, cz: cz, hw: w/2, hd: d/2 });
}
const quadrants = [
{ cx: 17, cz: 17 },
{ cx: -17, cz: 17 },
{ cx: 17, cz: -17 },
{ cx: -17, cz: -17 }
];
quadrants.forEach(function(q, qi){
const baseX = q.cx;
const baseZ = q.cz;
const count = 3 + Math.floor(Math.random()*2);
for (let i=0;i<count;i++){
let bx = baseX + (Math.random()-0.5)*14;
let bz = baseZ + (Math.random()-0.5)*14;
if (Math.abs(bx) < 5.5) bx += Math.sign(bx || 1) * 5.5;
if (Math.abs(bz) < 5.5) bz += Math.sign(bz || 1) * 5.5;
if (Math.abs(Math.abs(bx) - ringR) < 3.5) bx += (bx > 0 ? 4 : -4);
if (Math.abs(Math.abs(bz) - ringR) < 3.5) bz += (bz > 0 ? 4 : -4);
const w = 4 + Math.random()*4;
const d = 4 + Math.random()*4;
const h = 5 + Math.random()*3;
makeBuilding(bx, bz, w, d, h, qi*2 + i);
}
});
// 地标
(function(){
const poolGeo = new THREE.CircleGeometry(4, 24);
poolGeo.rotateX(-Math.PI/2);
const pool = new THREE.Mesh(poolGeo, new THREE.MeshLambertMaterial({color:0x3a7ab8}));
pool.position.set(29, 0.04, 29);
scene.add(pool);
const rimGeo = new THREE.TorusGeometry(4.2, 0.3, 8, 24);
const rim = new THREE.Mesh(rimGeo, new THREE.MeshLambertMaterial({color:0x8a8070}));
rim.rotation.x = Math.PI/2;
rim.position.set(29, 0.15, 29);
scene.add(rim);
for (let i=0;i<8;i++){
const a = i/8 * Math.PI*2;
circleObstacles.push({ x: 29 + Math.cos(a)*3.8, z: 29 + Math.sin(a)*3.8, r: 0.6 });
}
})();
(function(){
const trunk = new THREE.Mesh(
new THREE.CylinderGeometry(0.6, 0.9, 6, 8),
new THREE.MeshLambertMaterial({color:0x4a3520})
);
trunk.position.set(-29, 3, 29);
trunk.castShadow = true;
scene.add(trunk);
const crown = new THREE.Mesh(
new THREE.SphereGeometry(4, 12, 10),
new THREE.MeshLambertMaterial({color:0x3a7a32})
);
crown.position.set(-29, 7.5, 29);
crown.castShadow = true;
scene.add(crown);
circleObstacles.push({ x:-29, z:29, r:1.0 });
})();
(function(){
const stoneMat = new THREE.MeshLambertMaterial({color:0x9a9080});
for (let i=0;i<6;i++){
const a = i/6 * Math.PI*2;
const r = 2 + Math.random()*1.5;
const h = 1 + Math.random()*2.5;
const stone = new THREE.Mesh(
new THREE.CylinderGeometry(0.5, 0.6, h, 6),
stoneMat
);
stone.position.set(-29 + Math.cos(a)*r, h/2, -29 + Math.sin(a)*r);
stone.rotation.z = (Math.random()-0.5)*0.5;
stone.castShadow = true;
scene.add(stone);
circleObstacles.push({ x: stone.position.x, z: stone.position.z, r: 0.65 });
}
})();
(function(){
const tower = new THREE.Mesh(
new THREE.CylinderGeometry(1.4, 1.8, 7, 8),
new THREE.MeshLambertMaterial({color:0x8a7560})
);
tower.position.set(29, 3.5, -29);
tower.castShadow = true;
scene.add(tower);
const top = new THREE.Mesh(
new THREE.ConeGeometry(1.8, 2, 8),
new THREE.MeshLambertMaterial({color:0x5a3a2a})
);
top.position.set(29, 8, -29);
top.castShadow = true;
scene.add(top);
circleObstacles.push({ x:29, z:-29, r:2.0 });
})();
// 树木
const trunkMat = new THREE.MeshLambertMaterial({color:0x4a3520});
const leafMatA = new THREE.MeshLambertMaterial({color:0x2f6b2a});
const leafMatB = new THREE.MeshLambertMaterial({color:0x3b7d33});
const trunkGeo = new THREE.CylinderGeometry(0.25, 0.4, 3, 6);
const leafGeo = new THREE.ConeGeometry(1.6, 3.5, 7);
for (let i=0;i<55;i++){
const a = Math.random()*Math.PI*2;
const r = 5 + Math.random()*31;
const x = Math.cos(a)*r, z = Math.sin(a)*r;
if (Math.abs(x) < 4 || Math.abs(z) < 4) continue;
if (Math.abs(Math.abs(x) - ringR) < 3 && Math.abs(z) < ringR+3) continue;
if (Math.abs(Math.abs(z) - ringR) < 3 && Math.abs(x) < ringR+3) continue;
const s = 0.8 + Math.random()*0.6;
const trunk = new THREE.Mesh(trunkGeo, trunkMat);
trunk.position.set(x, 1.5*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.2*s, z);
leaf.scale.setScalar(s);
leaf.castShadow = true;
scene.add(leaf);
circleObstacles.push({ x:x, z:z, r:0.6*s });
}
// 灌木 / 石头
const rockMat = new THREE.MeshLambertMaterial({color:0x7b7f83});
const bushMat = new THREE.MeshLambertMaterial({color:0x35702c});
const rockGeo = new THREE.IcosahedronGeometry(0.6, 0);
const bushGeo = new THREE.SphereGeometry(0.7, 7, 6);
for (let i=0;i<45;i++){
const a = Math.random()*Math.PI*2, r = 4 + Math.random()*33;
const isRock = Math.random() < 0.4;
const s = 0.6 + Math.random()*0.7;
const px = Math.cos(a)*r, pz = Math.sin(a)*r;
if (Math.abs(px) < 4 || Math.abs(pz) < 4) continue;
const m = new THREE.Mesh(isRock ? rockGeo : bushGeo, isRock ? rockMat : bushMat);
m.position.set(px, isRock ? 0.25*s : 0.5*s, pz);
m.scale.set(s, s*0.8, s);
m.rotation.y = Math.random()*3;
m.castShadow = true;
scene.add(m);
if (isRock) circleObstacles.push({ x:px, z:pz, r:0.55*s });
}
// 路灯
const poleMat = new THREE.MeshLambertMaterial({color:0x3a3028});
const lampMat = new THREE.MeshBasicMaterial({color:0xfff0a0});
const lampPositions = [
[-26, -4], [-16, -4], [-6, -4], [6, -4], [16, -4], [26, -4],
[-26, 4], [-16, 4], [-6, 4], [6, 4], [16, 4], [26, 4],
[-4, -26], [-4, -16], [-4, -6], [-4, 6], [-4, 16], [-4, 26],
[ 4, -26], [ 4, -16], [ 4, -6], [ 4, 6], [ 4, 16], [ 4, 26]
];
lampPositions.forEach(function(p){
const pole = new THREE.Mesh(
new THREE.CylinderGeometry(0.08, 0.1, 3, 6),
poleMat
);
pole.position.set(p[0], 1.5, p[1]);
pole.castShadow = true;
scene.add(pole);
const lamp = new THREE.Mesh(new THREE.SphereGeometry(0.22, 8, 6), lampMat);
lamp.position.set(p[0], 3.1, p[1]);
scene.add(lamp);
});
})();
function insideObstacle(x, z, pad){
const p = pad || 0;
for (let i=0;i<circleObstacles.length;i++){
const o = circleObstacles[i];
const dx = x - o.x, dz = z - o.z;
const rr = o.r + p;
if (dx*dx + dz*dz < rr*rr) return true;
}
for (let i=0;i<rectObstacles.length;i++){
const o = rectObstacles[i];
const dx = Math.max(0, Math.abs(x - o.cx) - o.hw);
const dz = Math.max(0, Math.abs(z - o.cz) - o.hd);
if (dx*dx + dz*dz < p*p) 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');
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);
if (DENSITY > 0){
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);
if (DENSITY > 0){
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);
if (DENSITY > 0){
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);
if (DENSITY > 0){
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);
}
} else if (isTip) {
const cap = new THREE.Mesh(new THREE.SphereGeometry(s.r1 * 1.15, 10, 8), matLight);
cap.position.y = s.len;
pivot.add(cap);
}
attach.add(pivot);
attach = pivot;
tailPivots.push(pivot);
});
g.scale.setScalar(SCALE);
return { group: g, bodyPivot, headPivot, legs, tail: tailPivots };
}
/* ============================================================
6. 玩家实例
🌟 猫的 scale 从 1.15 缩到 0.42 —— 真正的小猫
============================================================ */
const PLAYER_RADIUS = 0.30; // 🌟 碰撞半径按比例缩小
const PC = createCat({
main: 0xE69A4E, light: 0xF6DEBE, dark: 0xB0682F, eye: 0x9BF25B,
texture: furTexOrange, density: 0.7, scale: 0.42
});
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',
attack: {
active: false,
timer: 0,
duration: 0.75,
cooldown: 0,
cooldownMax: 0.35,
hitSet: new Set()
}
};
const LEG_RF = 1, LEG_LF = 0, LEG_LH = 2, LEG_RH = 3;
/* ============================================================
7. 草药
============================================================ */
const herbs = [];
function spawnHerbAt(x, z){
const g = new THREE.Group();
const bulb = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.15, 0),
new THREE.MeshLambertMaterial({color:0x6dffa0, emissive:0x1c5c32})
);
bulb.position.y = 0.28;
g.add(bulb);
const stem = new THREE.Mesh(
new THREE.CylinderGeometry(0.025, 0.035, 0.25, 5),
new THREE.MeshLambertMaterial({color:0x2f7a3f})
);
stem.position.y = 0.13;
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 < 20; tries++){
const a = Math.random()*Math.PI*2;
const r = 2 + Math.random()*5;
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 > WORLD_HALF - 3){
const k = (WORLD_HALF - 3) / rad;
x *= k; z *= k;
}
if (!insideObstacle(x, z, 0.4)){
spawnHerbAt(x, z);
return;
}
}
}
/* ============================================================
8. 游戏状态
============================================================ */
const G = {
running: false, over: false,
hp: 100, maxHp: 100,
stamina: 100,
score: 0,
time: 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();
});
const dashBtn = document.getElementById('dashBtn');
dashBtn.addEventListener('pointerdown', function(e){
e.preventDefault();
tryDash();
});
document.addEventListener('touchmove', function(e){ e.preventDefault(); }, {passive:false});
document.addEventListener('gesturestart', function(e){ e.preventDefault(); });
/* ============================================================
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<circleObstacles.length;i++){
const o = circleObstacles[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;
}
}
for (let i=0;i<rectObstacles.length;i++){
const o = rectObstacles[i];
const closestX = Math.max(o.cx - o.hw, Math.min(pos.x, o.cx + o.hw));
const closestZ = Math.max(o.cz - o.hd, Math.min(pos.z, o.cz + o.hd));
const dx = pos.x - closestX;
const dz = pos.z - closestZ;
const d2 = dx*dx + dz*dz;
if (d2 < radius*radius){
if (d2 < 0.0001){
const leftD = pos.x - (o.cx - o.hw);
const rightD = (o.cx + o.hw) - pos.x;
const topD = pos.z - (o.cz - o.hd);
const botD = (o.cz + o.hd) - pos.z;
const minD = Math.min(leftD, rightD, topD, botD);
if (minD === leftD) pos.x = o.cx - o.hw - radius;
else if (minD === rightD) pos.x = o.cx + o.hw + radius;
else if (minD === topD) pos.z = o.cz - o.hd - radius;
else pos.z = o.cz + o.hd + radius;
} else {
const d = Math.sqrt(d2);
const push = (radius - d) / d;
pos.x += dx * push;
pos.z += dz * push;
}
}
}
}
pos.x = Math.max(-WORLD_HALF + 1, Math.min(WORLD_HALF - 1, pos.x));
pos.z = Math.max(-WORLD_HALF + 1, Math.min(WORLD_HALF - 1, pos.z));
}
/* ============================================================
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){
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 = -14;
const JUMP_VELOCITY = 6.2;
const DASH_SPEED = 5.5; // 冲刺速度
const DASH_DURATION = 0.8;
const DASH_COOLDOWN = 0.5;
const DASH_STAMINA_COST = 30;
const WALK_SPEED = 2.4; // 普通移动速度
function tryJump(){
if (!G.running || !P.onGround) return;
P.vy = JUMP_VELOCITY;
P.onGround = false;
}
function tryDash(){
if (!G.running) return;
if (P.dashTime > 0) return;
if (P.dashCd > 0) return;
if (G.stamina < DASH_STAMINA_COST) return;
G.stamina -= DASH_STAMINA_COST;
P.dashTime = DASH_DURATION;
P.dashCd = DASH_COOLDOWN;
}
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;
const dashing = P.dashTime > 0;
if (dashing) P.dashTime -= dt;
if (P.dashCd > 0) P.dashCd -= dt;
let speed = WALK_SPEED;
if (dashing) speed = DASH_SPEED;
let moveDirX = 0, moveDirZ = 0;
let didMove = false;
if (mag > 0){
moveDirX = moveInput.x;
moveDirZ = moveInput.y;
const l = Math.hypot(moveDirX, moveDirZ) || 1;
moveDirX /= l;
moveDirZ /= l;
const targetYaw = Math.atan2(moveDirX, moveDirZ);
P.yaw = lerpAngle(P.yaw, targetYaw, 1 - Math.exp(-18*dt));
didMove = true;
} else if (dashing){
moveDirX = Math.sin(P.yaw);
moveDirZ = Math.cos(P.yaw);
didMove = true;
mag = 1;
}
if (didMove){
const sp = speed * mag * dt;
PC.group.position.x += moveDirX * sp;
PC.group.position.z += moveDirZ * sp;
}
resolveCollisions(PC.group.position, PLAYER_RADIUS);
// 重力
let grounded = true;
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;
} else {
grounded = false;
}
}
PC.group.rotation.y = P.yaw;
let currentSpeed = 0;
if (didMove) 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 (!grounded){
// 空中:只调腿部姿势,不覆盖 y 位置(否则会跟重力打架)
P.walkTime += dt;
playBoundLegs(PC, P.walkTime);
} else if (dashing){
P.walkTime += dt * (currentSpeed / 3);
playGallopLegs(PC, P.walkTime);
} else if (currentSpeed > 0.3){
P.walkTime += dt * (currentSpeed / 2.4);
playWalkLegs(PC, P.walkTime, 1.0);
} else {
playIdle(PC, G.time);
}
if (playerAnim.attack.cooldown > 0) playerAnim.attack.cooldown -= 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();
}
/* ---- 空中动画(不覆盖 y) ---- */
function playBoundLegs(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 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;
}
}
/* ---- 冲刺腿部动画(不覆盖 y) ---- */
function playGallopLegs(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);
}
// 只加轻微的视觉起伏(不影响重力)
cat.group.position.y += Math.sin(phase * Math.PI * 4) * 0.05;
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;
}
}
/* ---- 走路腿部动画(不覆盖 y) ---- */
function playWalkLegs(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);
}
cat.group.position.y += Math.sin(phase * Math.PI * 4) * 0.02;
const swayX = Math.sin(phase * Math.PI * 2) * 0.06;
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;
}
}
/* ============================================================
13. 草药
============================================================ */
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.28 + Math.sin(h.t * 2.5) * 0.05;
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) < 0.7){
scene.remove(h.group);
herbs.splice(i, 1);
G.hp = Math.min(G.maxHp, G.hp + 25);
G.score += 5;
}
}
}
/* ============================================================
14. 相机
============================================================ */
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);
const lerpSpd = P.dashTime > 0 ? 11 : 7;
camera.position.lerp(_tmpDesired, 1 - Math.exp(-lerpSpd*dt));
_tmpLook.set(tp.x, tp.y + CAM_LOOK_Y, tp.z);
camLook.lerp(_tmpLook, 1 - Math.exp(-9*dt));
camera.lookAt(camLook);
}
/* ============================================================
15. HUD + 小地图
============================================================ */
const hpFillEl = document.getElementById('hpFill');
const stFillEl = document.getElementById('stFill');
const scoreEl = document.getElementById('score');
const waveEl = document.getElementById('waveLbl');
const hurtEl = document.getElementById('hurt');
const overlayEl = document.getElementById('overlay');
const overEl = document.getElementById('gameover');
const finalEl = document.getElementById('finalScore');
const minimapCanvas = document.getElementById('minimapCanvas');
const minimapCtx = minimapCanvas.getContext('2d');
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 = '自由探索';
if (playerAnim.attack.active || playerAnim.attack.cooldown > 0) {
atkBtn.classList.add('cool');
} else {
atkBtn.classList.remove('cool');
}
if (P.dashTime > 0 || P.dashCd > 0 || G.stamina < DASH_STAMINA_COST) {
dashBtn.classList.add('cool');
} else {
dashBtn.classList.remove('cool');
}
drawMinimap();
}
function drawMinimap(){
const W = minimapCanvas.width;
const H = minimapCanvas.height;
minimapCtx.clearRect(0, 0, W, H);
const scale = W / (WORLD_HALF * 2);
minimapCtx.fillStyle = 'rgba(60,90,50,0.9)';
minimapCtx.fillRect(0, 0, W, H);
minimapCtx.fillStyle = 'rgba(85,78,66,1)';
minimapCtx.fillRect(0, (WORLD_HALF - 3) * scale, W, 6 * scale);
minimapCtx.fillRect((WORLD_HALF - 3) * scale, 0, 6 * scale, H);
const ringR = 22, ringW = 4;
minimapCtx.fillRect((WORLD_HALF - ringR - ringW/2) * scale, (WORLD_HALF - ringR) * scale, ringR*2 * scale, ringW * scale);
minimapCtx.fillRect((WORLD_HALF - ringR - ringW/2) * scale, (WORLD_HALF + ringR - ringW/2) * scale, ringR*2 * scale, ringW * scale);
minimapCtx.fillRect((WORLD_HALF - ringR - ringW/2) * scale, (WORLD_HALF - ringR) * scale, ringW * scale, ringR*2 * scale);
minimapCtx.fillRect((WORLD_HALF + ringR - ringW/2) * scale, (WORLD_HALF - ringR) * scale, ringW * scale, ringR*2 * scale);
minimapCtx.fillStyle = 'rgba(138,111,74,1)';
rectObstacles.forEach(function(r){
const x = (r.cx + WORLD_HALF - r.hw) * scale;
const y = (r.cz + WORLD_HALF - r.hd) * scale;
minimapCtx.fillRect(x, y, r.hw*2*scale, r.hd*2*scale);
});
const px = (PC.group.position.x + WORLD_HALF) * scale;
const py = (PC.group.position.z + WORLD_HALF) * scale;
minimapCtx.save();
minimapCtx.translate(px, py);
minimapCtx.rotate(-P.yaw);
minimapCtx.fillStyle = '#ffffff';
minimapCtx.beginPath();
minimapCtx.moveTo(0, -4);
minimapCtx.lineTo(-3, 3);
minimapCtx.lineTo(3, 3);
minimapCtx.closePath();
minimapCtx.fill();
minimapCtx.restore();
}
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';
});
}
/* ============================================================
16. 流程控制
============================================================ */
function resetGame(){
for (let i=herbs.length-1;i>=0;i--) scene.remove(herbs[i].group);
herbs.length = 0;
P.yaw = 0; P.phase = 0;
P.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.time = 0;
G.herbTimer = 6;
G.over = false;
camera.position.set(0, CAM_OFFSET.y, CAM_OFFSET.z);
camLook.set(0, CAM_LOOK_Y, 0);
camera.lookAt(camLook);
updateHUD();
}
function startGame(){
resetGame();
overlayEl.classList.add('hide');
overEl.classList.add('hide');
G.running = true;
}
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('restartBtn').addEventListener('click', startGame);
/* ============================================================
17. 主循环
============================================================ */
const clock = new THREE.Clock();
function tick(){
const dt = Math.min(clock.getDelta(), 0.05);
if (G.running){
G.time += dt;
updatePlayer(dt);
updateHerbs(dt);
updateHUD();
} else {
G.time += dt * 0.6;
playIdle(PC, G.time);
}
updateCamera(dt);
renderer.render(scene, camera);
}
renderer.setAnimationLoop(tick);
})();
</script>
</body>
</html>Game Source: 猫武士 · 自由探索
Creator: TurboKoala83
Libraries: three
Complexity: complex (1800 lines, 63.2 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-munw1u4y" to link back to the original. Then publish at arcadelab.ai/publish.