猫武士 · 森林守卫
by TurboKoala83981 lines30.3 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 ---------- */
#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));}
#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);}
#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="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:false, 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, 38, 105);
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. 构建森林场景
============================================================ */
(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 leafMat = new THREE.MeshLambertMaterial({color:0x2f6b2a});
const leafMat2 = 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 ? leafMat : leafMat2);
leaf.position.set(x, 4.1*s, z);
leaf.scale.setScalar(s);
scene.add(leaf);
}
// 石头与灌木
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 m = new THREE.Mesh(isRock ? rockGeo : bushGeo, isRock ? rockMat : bushMat);
const s = 0.5 + Math.random()*0.9;
m.position.set(Math.cos(a)*r, isRock ? 0.2*s : 0.45*s, Math.sin(a)*r);
m.scale.set(s, s*0.8, s);
m.rotation.y = Math.random()*3;
scene.add(m);
}
})();
/* ============================================================
3. 低多边形猫(程序化建模)
============================================================ */
function createCat(opt){
const o = Object.assign({
body:0xd98b4a, belly:0xf5e3cb, ear:0xb8703a, eye:0x54ff9f, 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 darkMat = new THREE.MeshBasicMaterial({color:0x2a2a32});
const eyeMat = new THREE.MeshBasicMaterial({color:o.eye});
const noseMat = new THREE.MeshBasicMaterial({color:0x3a2a26});
/* 躯干 */
const torso = new THREE.Mesh(new THREE.SphereGeometry(0.55, 12, 9), bodyMat);
torso.scale.set(1, 0.85, 1.45);
torso.position.set(0, 0.78, -0.05);
model.add(torso);
/* 胸口浅毛 */
const chest = new THREE.Mesh(new THREE.SphereGeometry(0.4, 10, 8), bellyMat);
chest.scale.set(0.9, 0.85, 0.85);
chest.position.set(0, 0.62, 0.5);
model.add(chest);
/* 头 */
const head = new THREE.Mesh(new THREE.SphereGeometry(0.42, 12, 10), bodyMat);
head.scale.set(1, 0.95, 1);
head.position.set(0, 1.2, 0.78);
model.add(head);
/* 吻部 + 鼻子 */
const muzzle = new THREE.Mesh(new THREE.SphereGeometry(0.2, 10, 8), bellyMat);
muzzle.scale.set(1.15, 0.8, 1);
muzzle.position.set(0, 1.08, 1.12);
model.add(muzzle);
const nose = new THREE.Mesh(new THREE.SphereGeometry(0.055, 6, 5), noseMat);
nose.position.set(0, 1.12, 1.27);
model.add(nose);
/* 眼睛 */
[-1, 1].forEach(function(sx){
const eye = new THREE.Mesh(new THREE.SphereGeometry(0.078, 8, 7), eyeMat);
eye.position.set(0.17*sx, 1.28, 1.04);
model.add(eye);
const pupil = new THREE.Mesh(new THREE.SphereGeometry(0.036, 6, 5), darkMat);
pupil.position.set(0.17*sx, 1.28, 1.11);
model.add(pupil);
});
/* 耳朵 */
const earGeo = new THREE.ConeGeometry(0.17, 0.34, 5);
const earGeo2 = new THREE.ConeGeometry(0.09, 0.2, 5);
[-1, 1].forEach(function(sx){
const ear = new THREE.Mesh(earGeo, bodyMat);
ear.position.set(0.24*sx, 1.58, 0.72);
ear.rotation.z = -0.22*sx;
ear.rotation.x = -0.12;
model.add(ear);
const inner = new THREE.Mesh(earGeo2, earMat);
inner.position.set(0.24*sx, 1.56, 0.765);
inner.rotation.z = -0.22*sx;
inner.rotation.x = -0.12;
model.add(inner);
});
/* 四条腿(枢轴在髋部) */
const legGeo = new THREE.CylinderGeometry(0.105, 0.09, 0.62, 6);
legGeo.translate(0, -0.31, 0);
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(new THREE.SphereGeometry(0.11, 6, 5), bellyMat);
paw.scale.set(1, 0.7, 1.2);
paw.position.set(0, -0.62, 0.04);
leg.add(paw);
model.add(leg);
legs.push(leg);
});
/* 尾巴(球体链) */
const tail = [];
const TAIL_N = 6;
for (let i=0;i<TAIL_N;i++){
const r = 0.13 - i*0.012;
const seg = new THREE.Mesh(new THREE.SphereGeometry(r, 7, 6), bodyMat);
model.add(seg);
tail.push(seg);
}
/* 地面阴影 */
const shGeo = new THREE.CircleGeometry(0.85, 16);
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, scale:1.15});
PC.group.position.set(0, 0, 0);
scene.add(PC.group);
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, 16, 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 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({body:0x4a3f55, belly:0x6b5f78, ear:0x2e2635, eye:0xff4a4a, scale:1.1});
cat.group.position.set(x, 0, z);
scene.add(cat.group);
const hp = 60 + G.wave*14;
enemies.push({
group: cat.group, model: cat.model, legs: cat.legs, tail: cat.tail, mats: cat.mats,
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,
moving: 1
});
}
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(){
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; }
spawnHerbAt(x, z);
}
/* ============================================================
6. 游戏状态
============================================================ */
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:8
};
/* ============================================================
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 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; }
let dx = 0, dz = 0;
if (mag > 0){
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;
}
/* 世界边界 */
const r = Math.hypot(PC.group.position.x, PC.group.position.z);
if (r > 43){
PC.group.position.x *= 43/r;
PC.group.position.z *= 43/r;
}
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;
for (let i = enemies.length - 1; i >= 0; i--){
const e = enemies[i];
if (e.dead) continue;
const dx = e.group.position.x - px;
const dz = e.group.position.z - pz;
const d = Math.hypot(dx, dz);
if (d > 3.0) continue;
const nd = d < 0.001 ? 1 : d;
const dot = (dx/nd)*fx + (dz/nd)*fz;
if (dot < 0.15) continue;
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.group.position.x - sx;
const kz = e.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.group.position.x + (Math.random()-0.5)*1.5,
e.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();
}
}
/* ============================================================
10. 敌人逻辑
============================================================ */
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.group.scale.setScalar(1.1 * k);
e.group.position.y = (1 - k) * 0.6;
e.group.rotation.z += dt * 7;
if (e.deadTimer > 0.28){
scene.remove(e.group);
enemies.splice(i, 1);
}
continue;
}
const ep = e.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.phase += dt * 13;
e.moving = 1;
} else {
e.phase += dt * 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;
/* 朝向玩家 */
const ty = Math.atan2(dx, dz);
e.group.rotation.y = lerpAngle(e.group.rotation.y, ty, 1 - Math.exp(-10*dt));
/* 腿 & 尾巴 */
const sw = Math.sin(e.phase) * 0.7 * e.moving;
e.legs[0].rotation.x = sw;
e.legs[1].rotation.x = -sw;
e.legs[2].rotation.x = -sw;
e.legs[3].rotation.x = sw;
const tn = e.tail.length;
for (let k=0;k<tn;k++){
const p = k/(tn-1);
e.tail[k].position.set(
Math.sin(G.time*3 - p*2.3) * 0.3 * p,
0.95 + p*0.55 - p*p*0.5,
-0.85 - p*0.62
);
}
/* 受击闪红恢复 */
if (e.flash > 0){
e.flash -= dt;
if (e.flash <= 0){
e.mats.forEach(function(m){ if (m.emissive) m.emissive.setHex(0x000000); });
}
}
}
}
/* ============================================================
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;
}
}
}
/* ============================================================
12. 波次系统
============================================================ */
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 < 7){
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();
}
}
}
/* ============================================================
13. 相机
============================================================ */
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);
}
/* ============================================================
14. 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 + ' 波';
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';
});
}
/* ============================================================
15. 流程控制
============================================================ */
function resetGame(){
for (let i=enemies.length-1;i>=0;i--) scene.remove(enemies[i].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.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.wave = 1;
G.time = 0;
G.toSpawn = 0;
G.spawnTimer = 0;
G.resting = false;
G.restTimer = 0;
G.herbTimer = 8;
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;
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);
/* ============================================================
16. 主循环
============================================================ */
const clock = new THREE.Clock();
function tick(){
const dt = Math.min(clock.getDelta(), 0.05);
if (G.running){
G.time += dt;
updatePlayer(dt);
updateEnemies(dt);
updateHerbs(dt);
updateWaves(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 (981 lines, 30.3 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" to link back to the original. Then publish at arcadelab.ai/publish.