猫武士 · 森林漫游
by TurboKoala83902 lines28.9 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:28px;font-weight:800;letter-spacing:4px;opacity:0;transition:opacity .5s;
text-shadow:0 4px 18px rgba(0,0,0,.8);}
#stick{position:absolute;left:22px;bottom:calc(34px + env(safe-area-inset-bottom));
width:132px;height:132px;border-radius:50%;pointer-events:auto;touch-action:none;
background:radial-gradient(circle,rgba(255,255,255,.13),rgba(255,255,255,.03));
border:2px solid rgba(255,255,255,.3);
box-shadow:0 0 22px rgba(0,0,0,.35) inset;}
#knob{position:absolute;left:50%;top:50%;width:58px;height:58px;margin:-29px 0 0 -29px;
border-radius:50%;pointer-events:none;
background:radial-gradient(circle at 35% 30%,rgba(255,255,255,.92),rgba(160,210,255,.55));
box-shadow:0 4px 16px rgba(0,0,0,.45);}
#btns{position:absolute;right:20px;bottom:calc(34px + env(safe-area-inset-bottom));
display:flex;align-items:flex-end;gap:14px;pointer-events:none;}
.btn{pointer-events:auto;touch-action:none;border-radius:50%;color:#fff;
font-weight:800;display:flex;align-items:center;justify-content:center;
border:2px solid rgba(255,255,255,.38);text-shadow:0 2px 6px rgba(0,0,0,.65);
transition:transform .07s;font-family:inherit;}
.btn:active{transform:scale(.92);}
#atkBtn{width:96px;height:96px;font-size:20px;letter-spacing:2px;
background:radial-gradient(circle at 35% 30%,rgba(255,150,95,.75),rgba(190,55,40,.62));}
#dashBtn{width:66px;height:66px;font-size:14px;letter-spacing:1px;
background:radial-gradient(circle at 35% 30%,rgba(120,205,255,.7),rgba(35,105,190,.6));}
.btn.cool{opacity:.42;}
#hurt{position:fixed;inset:0;pointer-events:none;z-index:15;opacity:0;
background:radial-gradient(circle,rgba(255,0,0,0) 35%,rgba(255,20,20,.85) 100%);}
.overlay{position:fixed;inset:0;z-index:20;display:flex;flex-direction:column;
align-items:center;justify-content:center;padding:26px;color:#fff;text-align:center;
background:radial-gradient(circle at 50% 38%,rgba(28,52,72,.9),rgba(4,9,14,.97));
backdrop-filter:blur(3px);}
.overlay.hide{display:none;}
.overlay h1{font-size:40px;letter-spacing:8px;font-weight:900;
background:linear-gradient(180deg,#fff,#8fd8ff);-webkit-background-clip:text;
background-clip:text;color:transparent;text-shadow:0 0 40px rgba(120,200,255,.35);}
.sub{font-size:12px;letter-spacing:4px;opacity:.6;margin-top:8px;}
.howto{margin:28px 0 30px;font-size:14px;line-height:2.05;opacity:.82;}
.howto p{letter-spacing:1px;}
.bigbtn{pointer-events:auto;font-family:inherit;font-size:18px;font-weight:800;letter-spacing:4px;
color:#08131c;padding:15px 46px;border:none;border-radius:40px;cursor:pointer;
background:linear-gradient(180deg,#a8ecff,#4fb8ff);
box-shadow:0 8px 26px rgba(70,180,255,.45);}
.bigbtn:active{transform:scale(.96);}
</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="stick"><div id="knob"></div></div>
<div id="btns">
<button class="btn" id="dashBtn">冲刺</button>
<button class="btn" id="atkBtn">挥爪</button>
</div>
</div>
<div id="hurt"></div>
<div class="overlay" id="overlay">
<h1>猫武士</h1>
<div class="sub">FOREST WANDERER</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" style="font-size:15px;opacity:.85;margin:6px 0 26px;">你的战果<b style="font-size:30px;color:#ffd479;display:block;">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);
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);
scene.add(sun);
window.addEventListener('resize', function(){
camera.aspect = window.innerWidth/window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
/* ============================================================
2. 森林场景 + 碰撞体收集
============================================================ */
const obstacles = []; // { x, z, r }
(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);
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);
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 m = new THREE.Mesh(isRock ? rockGeo : bushGeo, isRock ? rockMat : bushMat);
const px = Math.cos(a)*r, pz = Math.sin(a)*r;
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;
scene.add(m);
if (isRock){
obstacles.push({ x:px, z:pz, r:0.6*s });
}
}
})();
/* ============================================================
3. 改良版低多边形猫猫
============================================================ */
function createCat(opt){
const o = Object.assign({
body:0xe0914a, // 主体毛色
belly:0xfff0dc, // 肚皮 / 口鼻 / 爪子
ear:0xb56b34, // 耳内 / 斑纹
eye:0x5cff9d, // 眼睛
nose:0xff9aa2, // 鼻子
scale:1
}, opt || {});
const group = new THREE.Group();
const model = new THREE.Group();
group.add(model);
const mats = [];
function lam(col){
const m = new THREE.MeshLambertMaterial({color:col});
mats.push(m);
return m;
}
const bodyMat = lam(o.body);
const bellyMat = lam(o.belly);
const earMat = lam(o.ear);
const eyeMat = new THREE.MeshBasicMaterial({color:o.eye});
const pupilMat = new THREE.MeshBasicMaterial({color:0x1a1a20});
const shineMat = new THREE.MeshBasicMaterial({color:0xffffff});
const noseMat = new THREE.MeshBasicMaterial({color:o.nose});
const whiskerMat = new THREE.MeshBasicMaterial({color:0xf5f5f5, transparent:true, opacity:0.8});
/* ---------- 躯干 ---------- */
const torso = new THREE.Mesh(new THREE.SphereGeometry(0.55, 14, 11), bodyMat);
torso.scale.set(1, 0.9, 1.4);
torso.position.set(0, 0.8, -0.05);
model.add(torso);
// 后臀
const hip = new THREE.Mesh(new THREE.SphereGeometry(0.5, 12, 9), bodyMat);
hip.scale.set(1, 0.95, 1);
hip.position.set(0, 0.82, -0.5);
model.add(hip);
// 胸口浅毛
const chest = new THREE.Mesh(new THREE.SphereGeometry(0.42, 12, 9), bellyMat);
chest.scale.set(0.9, 0.86, 0.88);
chest.position.set(0, 0.6, 0.46);
model.add(chest);
/* ---------- 虎斑背纹 ---------- */
const stripeGeo = new THREE.SphereGeometry(0.14, 7, 5);
for (let i=0;i<4;i++){
const st = new THREE.Mesh(stripeGeo, earMat);
st.scale.set(1.5, 0.32, 0.55);
st.position.set(0, 1.24 - i*0.015, 0.28 - i*0.26);
model.add(st);
}
/* ---------- 头部 ---------- */
const head = new THREE.Mesh(new THREE.SphereGeometry(0.42, 14, 11), bodyMat);
head.scale.set(1.05, 0.98, 1);
head.position.set(0, 1.2, 0.78);
model.add(head);
// 脸颊
[-1, 1].forEach(function(sx){
const cheek = new THREE.Mesh(new THREE.SphereGeometry(0.17, 9, 8), bellyMat);
cheek.scale.set(1, 0.88, 0.9);
cheek.position.set(0.2*sx, 1.1, 0.92);
model.add(cheek);
});
// 口鼻
const muzzle = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 8), bellyMat);
muzzle.scale.set(1.18, 0.8, 1);
muzzle.position.set(0, 1.08, 1.05);
model.add(muzzle);
// 鼻子
const nose = new THREE.Mesh(new THREE.SphereGeometry(0.055, 7, 6), noseMat);
nose.scale.set(1.35, 0.9, 1);
nose.position.set(0, 1.13, 1.24);
model.add(nose);
// 嘴线
const mouthMat = new THREE.MeshBasicMaterial({color:0x8a5a48});
[-1, 1].forEach(function(sx){
const m = new THREE.Mesh(new THREE.SphereGeometry(0.055, 6, 5), mouthMat);
m.scale.set(0.5, 0.18, 0.5);
m.position.set(0.06*sx, 1.03, 1.18);
m.rotation.z = 0.55*sx;
model.add(m);
});
/* ---------- 眼睛 ---------- */
[-1, 1].forEach(function(sx){
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.088, 10, 9), eyeMat);
eye.scale.set(1, 1.05, 0.72);
eye.position.set(0.175*sx, 1.29, 1.02);
model.add(eye);
const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.042, 8, 7), pupilMat);
pupil.scale.set(0.42, 1, 0.7);
pupil.position.set(0.175*sx, 1.29, 1.075);
model.add(pupil);
const shine = new THREE.Mesh(new THREE.SphereGeometry(0.022, 6, 5), shineMat);
shine.position.set(0.145*sx, 1.335, 1.085);
model.add(shine);
});
/* ---------- 耳朵 ---------- */
const earOuterGeo = new THREE.ConeGeometry(0.18, 0.36, 6);
const earInnerGeo = new THREE.ConeGeometry(0.1, 0.24, 6);
[-1, 1].forEach(function(sx){
const ear = new THREE.Mesh(earOuterGeo, bodyMat);
ear.position.set(0.245*sx, 1.57, 0.72);
ear.rotation.z = -0.24*sx;
ear.rotation.x = -0.1;
model.add(ear);
const inner = new THREE.Mesh(earInnerGeo, earMat);
inner.position.set(0.24*sx, 1.545, 0.755);
inner.rotation.z = -0.24*sx;
inner.rotation.x = -0.1;
model.add(inner);
});
/* ---------- 胡须 ---------- */
const whiskerGeo = new THREE.CylinderGeometry(0.008, 0.003, 0.42, 3);
[-1, 1].forEach(function(sx){
for (let i=0;i<3;i++){
const offset = (i - 1) * 0.26; // -0.26 / 0 / 0.26
const wg = new THREE.Group();
wg.position.set(0.17*sx, 1.12, 1.06);
wg.rotation.z = -sx * (Math.PI/2 + offset);
const w = new THREE.Mesh(whiskerGeo, whiskerMat);
w.position.y = 0.21;
wg.add(w);
model.add(wg);
}
});
/* ---------- 四条腿 ---------- */
const legGeo = new THREE.CylinderGeometry(0.105, 0.088, 0.62, 7);
legGeo.translate(0, -0.31, 0);
const pawGeo = new THREE.SphereGeometry(0.115, 8, 6);
const legs = [];
[[0.30,0.52],[-0.30,0.52],[0.32,-0.50],[-0.32,-0.50]].forEach(function(p){
const leg = new THREE.Mesh(legGeo, bodyMat);
leg.position.set(p[0], 0.62, p[1]);
const paw = new THREE.Mesh(pawGeo, bellyMat);
paw.scale.set(1, 0.68, 1.28);
paw.position.set(0, -0.6, 0.05);
leg.add(paw);
// 脚趾
for (let t=-1;t<=1;t++){
const toe = new THREE.Mesh(new THREE.SphereGeometry(0.032, 6, 5), bellyMat);
toe.position.set(t*0.055, -0.63, 0.15);
leg.add(toe);
}
model.add(leg);
legs.push(leg);
});
/* ---------- 尾巴 ---------- */
const tail = [];
const TAIL_N = 7;
const tailTipGeo = new THREE.SphereGeometry(1, 8, 7);
for (let i=0;i<TAIL_N;i++){
const t = i/(TAIL_N-1);
const r = 0.14 - t*0.055;
const isTip = (i >= TAIL_N-2);
const seg = new THREE.Mesh(tailTipGeo, isTip ? bellyMat : bodyMat);
seg.scale.setScalar(r);
model.add(seg);
tail.push(seg);
}
/* ---------- 地面阴影 ---------- */
const shGeo = new THREE.CircleGeometry(0.85, 18);
shGeo.rotateX(-Math.PI/2);
const shadow = new THREE.Mesh(shGeo, new THREE.MeshBasicMaterial({
color:0x000000, transparent:true, opacity:0.2, depthWrite:false
}));
shadow.position.y = 0.02;
group.add(shadow);
group.scale.setScalar(o.scale);
return {group:group, model:model, legs:legs, tail:tail, mats:mats};
}
/* ============================================================
4. 玩家
============================================================ */
const PC = createCat({
body:0xe0914a, belly:0xfff0dc, ear:0xb56b34,
eye:0x5cff9d, nose:0xff9aa2, scale:1.15
});
PC.group.position.set(0, 0, 0);
scene.add(PC.group);
const PLAYER_RADIUS = 0.72;
const P = {
yaw: 0, phase: 0,
atkCd: 0, atkTimer: 0,
dashTime: 0, dashCd: 0,
invuln: 0
};
/* 爪击弧光 */
const slashGroup = new THREE.Group();
const slashMesh = new THREE.Mesh(
new THREE.RingGeometry(1.0, 1.95, 18, 1, -Math.PI*0.85, Math.PI*0.7),
new THREE.MeshBasicMaterial({color:0xffffff, transparent:true, opacity:0,
side:THREE.DoubleSide, depthWrite:false})
);
slashMesh.rotation.x = -Math.PI/2;
slashGroup.add(slashMesh);
slashGroup.visible = false;
scene.add(slashGroup);
let slashT = 0;
/* ============================================================
5. 草药
============================================================ */
const herbs = [];
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;
}
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;
}
}
}
/* ============================================================
6. 游戏状态
============================================================ */
const G = {
running:false, over:false,
hp:100, maxHp:100,
stamina:100,
score:0,
herbsPicked:0,
time:0,
herbTimer:6
};
/* ============================================================
7. 输入
============================================================ */
const moveInput = {x:0, y:0};
const stickEl = document.getElementById('stick');
const knobEl = document.getElementById('knob');
const STICK_R = 52;
let stickPointer = null;
function stickUpdate(e){
const r = stickEl.getBoundingClientRect();
const cx = r.left + r.width/2, cy = r.top + r.height/2;
let dx = e.clientX - cx, dy = e.clientY - cy;
const len = Math.hypot(dx, dy);
if (len > STICK_R){ dx = dx/len*STICK_R; dy = dy/len*STICK_R; }
knobEl.style.transform = 'translate(' + dx + 'px,' + dy + 'px)';
if (len < 8){ moveInput.x = 0; moveInput.y = 0; }
else { moveInput.x = dx/STICK_R; moveInput.y = dy/STICK_R; }
}
stickEl.addEventListener('pointerdown', function(e){
e.preventDefault();
if (stickPointer !== null) return;
stickPointer = e.pointerId;
try{ stickEl.setPointerCapture(e.pointerId); }catch(err){}
stickUpdate(e);
});
stickEl.addEventListener('pointermove', function(e){
if (e.pointerId !== stickPointer) return;
e.preventDefault();
stickUpdate(e);
});
function stickEnd(e){
if (e.pointerId !== stickPointer) return;
stickPointer = null;
moveInput.x = 0; moveInput.y = 0;
knobEl.style.transform = 'translate(0px,0px)';
}
stickEl.addEventListener('pointerup', stickEnd);
stickEl.addEventListener('pointercancel', stickEnd);
const atkBtn = document.getElementById('atkBtn');
let attackHeld = false;
atkBtn.addEventListener('pointerdown', function(e){
e.preventDefault(); attackHeld = true;
try{ atkBtn.setPointerCapture(e.pointerId); }catch(err){}
});
atkBtn.addEventListener('pointerup', function(){ attackHeld = false; });
atkBtn.addEventListener('pointercancel', function(){ attackHeld = false; });
const dashBtn = document.getElementById('dashBtn');
dashBtn.addEventListener('pointerdown', function(e){
e.preventDefault();
tryDash();
});
document.addEventListener('touchmove', function(e){ e.preventDefault(); }, {passive:false});
document.addEventListener('gesturestart', function(e){ e.preventDefault(); });
/* ============================================================
8. 工具函数
============================================================ */
function lerpAngle(a, b, t){
let d = (b - a) % (Math.PI*2);
if (d > Math.PI) d -= Math.PI*2;
if (d < -Math.PI) d += Math.PI*2;
return a + d*t;
}
/* ============================================================
9. 碰撞解析(圆形推出,可沿墙滑动)
============================================================ */
function resolveCollisions(pos){
// 两轮迭代,处理夹角处更稳定
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 + PLAYER_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;
}
}
/* ============================================================
10. 玩家逻辑
============================================================ */
function updatePlayer(dt){
let mag = Math.hypot(moveInput.x, moveInput.y);
if (mag < 0.13) mag = 0;
if (mag > 1) mag = 1;
let speed = 5.8;
const dashing = P.dashTime > 0;
if (dashing){ speed = 12.5; P.dashTime -= dt; }
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);
PC.group.rotation.y = P.yaw;
/* ---- 四足奔跑 ---- */
const rate = dashing ? 26 : 14;
if (mag > 0) P.phase += dt * rate * mag;
else P.phase += dt * 2.4;
const amp = mag * (dashing ? 0.95 : 0.72);
const sw = Math.sin(P.phase) * amp;
PC.legs[0].rotation.x = sw;
PC.legs[1].rotation.x = -sw;
PC.legs[2].rotation.x = -sw;
PC.legs[3].rotation.x = sw;
/* ---- 身体起伏 ---- */
const bob = mag > 0
? Math.abs(Math.sin(P.phase)) * 0.075 * (dashing ? 1.5 : 1)
: Math.sin(P.phase*0.9) * 0.018;
PC.model.position.y = bob;
/* ---- 尾巴 ---- */
const tailN = PC.tail.length;
for (let i=0;i<tailN;i++){
const p = i/(tailN-1);
const seg = PC.tail[i];
seg.position.set(
Math.sin(G.time*3.2 - p*2.3) * 0.3 * p,
0.95 + p*0.55 - p*p*0.5 + Math.sin(G.time*3)*0.025*p,
-0.85 - p*0.62
);
}
/* ---- 挥爪前扑 ---- */
if (P.atkTimer > 0){
P.atkTimer -= dt;
const k = 1 - Math.max(0, P.atkTimer) / 0.22;
PC.model.position.z = Math.sin(k * Math.PI) * 0.5;
} else {
PC.model.position.z = 0;
}
/* ---- 计时器 ---- */
if (P.atkCd > 0) P.atkCd -= dt;
if (P.dashCd > 0) P.dashCd -= dt;
if (P.invuln > 0) P.invuln -= dt;
if (G.stamina < 100) G.stamina = Math.min(100, G.stamina + 24*dt);
if (attackHeld && P.atkCd <= 0) doAttack();
}
function tryDash(){
if (!G.running) return;
if (P.dashCd > 0 || G.stamina < 30) return;
G.stamina -= 30;
P.dashTime = 0.22;
P.dashCd = 0.85;
P.invuln = Math.max(P.invuln, 0.25);
}
function doAttack(){
P.atkCd = 0.42;
P.atkTimer = 0.22;
const px = PC.group.position.x, pz = PC.group.position.z;
const fx = Math.sin(P.yaw), fz = Math.cos(P.yaw);
slashGroup.position.set(px + fx*0.9, 0.55, pz + fz*0.9);
slashGroup.rotation.y = P.yaw;
slashGroup.visible = true;
slashT = 1;
}
/* ============================================================
11. 草药更新
============================================================ */
function updateHerbs(dt){
G.herbTimer -= dt;
if (G.herbTimer <= 0 && herbs.length < 4){
G.herbTimer = 9 + Math.random()*7;
spawnHerbNearPlayer();
}
for (let i = herbs.length - 1; i >= 0; i--){
const h = herbs[i];
h.t += dt;
h.bulb.rotation.y += dt * 2;
h.bulb.position.y = 0.55 + Math.sin(h.t * 2.5) * 0.1;
const dx = h.group.position.x - PC.group.position.x;
const dz = h.group.position.z - PC.group.position.z;
if (Math.hypot(dx, dz) < 1.7){
scene.remove(h.group);
herbs.splice(i, 1);
G.hp = Math.min(G.maxHp, G.hp + 25);
G.score += 5;
G.herbsPicked++;
}
}
}
/* ============================================================
12. 相机
============================================================ */
const _tmpDesired = new THREE.Vector3();
const _tmpLook = new THREE.Vector3();
function updateCamera(dt){
const tp = PC.group.position;
_tmpDesired.set(tp.x, 0, tp.z).add(CAM_OFFSET);
camera.position.lerp(_tmpDesired, 1 - Math.exp(-7*dt));
_tmpLook.set(tp.x, tp.y + CAM_LOOK_Y, tp.z);
camLook.lerp(_tmpLook, 1 - Math.exp(-9*dt));
camera.lookAt(camLook);
}
/* ============================================================
13. 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 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'; }, 1200);
}
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.herbsPicked;
dashBtn.classList.toggle('cool', G.stamina < 30 || P.dashCd > 0);
}
function flashHurt(){
hurtEl.style.transition = 'none';
hurtEl.style.opacity = '0.9';
requestAnimationFrame(function(){
hurtEl.style.transition = 'opacity 0.42s ease-out';
hurtEl.style.opacity = '0';
});
}
/* ============================================================
14. 流程控制
============================================================ */
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.atkCd = 0; P.atkTimer = 0;
P.dashTime = 0; P.dashCd = 0; P.invuln = 0;
PC.group.position.set(0, 0, 0);
PC.group.rotation.y = 0;
PC.model.position.set(0, 0, 0);
G.hp = G.maxHp;
G.stamina = 100;
G.score = 0;
G.herbsPicked = 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);
slashGroup.visible = false;
updateHUD();
}
function startGame(){
resetGame();
overlayEl.classList.add('hide');
overEl.classList.add('hide');
G.running = true;
showTip('森林漫游');
}
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 style="font-size:30px;color:#ffd479;display:block;">' + G.score + '</b>';
overEl.classList.remove('hide');
}
document.getElementById('startBtn').addEventListener('click', startGame);
document.getElementById('restartBtn').addEventListener('click', startGame);
/* ============================================================
15. 主循环
============================================================ */
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;
PC.model.position.y = Math.sin(G.time * 1.5) * 0.02;
const tn = PC.tail.length;
for (let i=0;i<tn;i++){
const p = i/(tn-1);
PC.tail[i].position.set(
Math.sin(G.time*2 - p*2.3) * 0.3 * p,
0.95 + p*0.55 - p*p*0.5,
-0.85 - p*0.62
);
}
}
/* 爪光衰减 */
if (slashT > 0){
slashT -= dt * 4.5;
slashMesh.material.opacity = Math.max(0, slashT) * 0.85;
const s = 1 + (1 - Math.max(0, slashT)) * 0.35;
slashGroup.scale.set(s, 1, s);
if (slashT <= 0) slashGroup.visible = false;
}
updateCamera(dt);
renderer.render(scene, camera);
}
renderer.setAnimationLoop(tick);
})();
</script>
</body>
</html>Game Source: 猫武士 · 森林漫游
Creator: TurboKoala83
Libraries: three
Complexity: complex (902 lines, 28.9 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-mujfbb66" to link back to the original. Then publish at arcadelab.ai/publish.