NBAgame / index.html
WACATW's picture
Update index.html
7b22361 verified
Raw History Blame Contribute Delete
47 kB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>2.5D 篮球对战 · 运球·抢断·扣篮</title>
<style>
body {
margin: 0; min-height: 100vh; display: flex;
align-items: center; justify-content: center;
background: #05060a; font-family: system-ui, sans-serif;
}
canvas { display: block; border-radius: 14px; box-shadow: 0 25px 60px rgba(0,0,0,0.85); }
</style>
</head>
<body>
<canvas id="game" width="1280" height="720"></canvas>
<script>
(() => {
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
// ============================================================
// 常量 & 2.5D 球场参数
// ============================================================
const W = 1280, H = 720;
const COURT_LEN = 1800;
const COURT_DEPTH = 900;
const NEAR_Y = 645;
const FAR_Y = 300;
const COURT_SCREEN_W = 1250;
const GRAVITY = 1900;
const PLAYER_SCALE = 0.72;
const BALL_R_WORLD = 15;
const HOOP_L = { cx: 120, cz: COURT_DEPTH / 2, cy: 168, r: 58, back: 30 };
const HOOP_R = { cx: COURT_LEN - 120, cz: COURT_DEPTH / 2, cy: 168, r: 58, back: COURT_LEN - 30 };
function w2s(cx, cz, cy) {
cy = cy || 0;
const t = clamp(cz / COURT_DEPTH, 0, 1);
const scale = 1 - 0.34 * t;
const sw = COURT_SCREEN_W * scale;
const sx = W / 2 + (cx / COURT_LEN - 0.5) * sw;
const baseY = NEAR_Y + (FAR_Y - NEAR_Y) * t;
const sy = baseY - cy * scale;
return { x: sx, y: sy, scale };
}
// ============================================================
// 工具
// ============================================================
function clamp(v, a, b) { return v < a ? a : v > b ? b : v; }
function lerp(a, b, t) { return a + (b - a) * t; }
function lerpAngle(a, b, t) {
let d = ((b - a + 180) % 360 + 360) % 360 - 180;
return a + d * t;
}
function smoothstep(u) { return u * u * (3 - 2 * u); }
function dist2D(x1, z1, x2, z2) { return Math.hypot(x1 - x2, z1 - z2); }
// ============================================================
// 占位图片
// ============================================================
function makePlaceholder(w, h, color, kind) {
const c = document.createElement('canvas');
c.width = w; c.height = h;
const g = c.getContext('2d');
g.fillStyle = color;
if (kind === 'head' || kind === 'ball') {
g.beginPath();
g.arc(w / 2, h / 2, Math.min(w, h) / 2 - 1, 0, Math.PI * 2);
g.fill();
if (kind === 'ball') {
g.strokeStyle = '#7a3d12'; g.lineWidth = 2;
g.beginPath(); g.moveTo(0, h / 2); g.lineTo(w, h / 2);
g.moveTo(w / 2, 0); g.lineTo(w / 2, h); g.stroke();
g.beginPath(); g.ellipse(w / 2, h / 2, w * 0.3, h / 2 - 1, 0, 0, Math.PI * 2); g.stroke();
}
} else {
const r = Math.min(w, h) / 2;
g.beginPath();
if (g.roundRect) g.roundRect(0, 0, w, h, r); else g.rect(0, 0, w, h);
g.fill();
}
return c;
}
function buildImgs(s) {
return {
pelvis: makePlaceholder(56, 40, s.j1, 'limb'),
torso: makePlaceholder(62, 90, s.j2, 'limb'),
head: makePlaceholder(46, 46, s.skin, 'head'),
upper_arm: makePlaceholder(24, 62, s.skin, 'limb'),
lower_arm: makePlaceholder(22, 56, s.skin, 'limb'),
hand: makePlaceholder(20, 20, s.skin, 'limb'),
thigh: makePlaceholder(30, 72, s.skin, 'limb'),
shin: makePlaceholder(24, 66, s.skin, 'limb'),
foot: makePlaceholder(42, 18, s.shoe, 'limb'),
};
}
const BALL_IMG = makePlaceholder(26, 26, '#E6822D', 'ball');
const IMGS_P = buildImgs({ j1: '#283C96', j2: '#3C5AC8', skin: '#EBC3A0', shoe: '#2D2D37' });
const IMGS_A = buildImgs({ j1: '#8B1A1A', j2: '#C0392B', skin: '#D9A47A', shoe: '#1A1A22' });
// ============================================================
// 骨骼 & 动画
// ============================================================
class Bone {
constructor(name, image, parent, offset, angle, pivot, z) {
this.name = name; this.image = image; this.parent = parent || null;
this.offset = { x: offset[0], y: offset[1] };
this.baseAngle = angle || 0;
this.animAngle = 0; this.animOffset = { x: 0, y: 0 };
this.pivot = pivot || (image ? [image.width / 2, image.height / 2] : [0, 0]);
this.z = z || 0;
this.children = [];
if (parent) parent.children.push(this);
this.worldPos = { x: 0, y: 0 };
this.worldAngle = 0;
this.mirror = false;
this.mirrorImage = null;
if (image) {
const c = document.createElement('canvas');
c.width = image.width; c.height = image.height;
const g = c.getContext('2d');
g.translate(c.width, 0); g.scale(-1, 1);
g.drawImage(image, 0, 0);
this.mirrorImage = c;
}
}
resetPose() { this.animAngle = 0; this.animOffset.x = 0; this.animOffset.y = 0; }
updateTransform(pp, pa) {
const ox = this.offset.x + this.animOffset.x;
const oy = this.offset.y + this.animOffset.y;
const ang = this.baseAngle + this.animAngle;
const r = pa * Math.PI / 180;
const c = Math.cos(r), s = Math.sin(r);
this.worldPos.x = pp.x + ox * c - oy * s;
this.worldPos.y = pp.y + ox * s + oy * c;
this.worldAngle = pa + ang;
for (const ch of this.children) ch.updateTransform(this.worldPos, this.worldAngle);
}
draw(g) {
if (!this.image) return;
const img = this.mirror ? this.mirrorImage : this.image;
const px = this.mirror ? (this.image.width - this.pivot[0]) : this.pivot[0];
const py = this.pivot[1];
g.save();
g.translate(this.worldPos.x, this.worldPos.y);
g.rotate(this.worldAngle * Math.PI / 180);
g.drawImage(img, -px, -py);
g.restore();
}
}
class Skeleton {
constructor(root) {
this.root = root; this.bones = {};
this._collect(root);
}
_collect(b) { this.bones[b.name] = b; for (const c of b.children) this._collect(c); }
update(pos, mirror) {
this.root.updateTransform({ x: 0, y: 0 }, 0);
const flip = mirror ? -1 : 1;
for (const b of Object.values(this.bones)) {
b.worldPos.x = b.worldPos.x * flip + pos.x;
b.worldPos.y = b.worldPos.y + pos.y;
b.worldAngle = b.worldAngle * flip;
b.mirror = mirror;
}
}
draw(g) {
const list = Object.values(this.bones).sort((a, b) => a.z - b.z);
for (const b of list) b.draw(g);
}
}
class Animation {
constructor(name, duration, loop) {
this.name = name; this.duration = duration; this.loop = loop !== false;
this.tracks = {};
}
addKey(bn, t, angle, offset) {
if (!this.tracks[bn]) this.tracks[bn] = [];
this.tracks[bn].push({ t, angle: angle || 0, offset: offset || [0, 0] });
this.tracks[bn].sort((a, b) => a.t - b.t);
}
sample(time) {
let t = time;
if (this.loop && this.duration > 0) t = ((time % this.duration) + this.duration) % this.duration;
else t = clamp(time, 0, this.duration);
const res = {};
for (const [n, keys] of Object.entries(this.tracks)) res[n] = this._st(keys, t);
return res;
}
_st(keys, t) {
if (!keys.length) return { angle: 0, offset: [0, 0] };
if (t <= keys[0].t) return { angle: keys[0].angle, offset: keys[0].offset };
const last = keys[keys.length - 1];
if (t >= last.t) return { angle: last.angle, offset: last.offset };
for (let i = 0; i < keys.length - 1; i++) {
const k0 = keys[i], k1 = keys[i + 1];
if (t >= k0.t && t <= k1.t) {
const u = k1.t === k0.t ? 0 : (t - k0.t) / (k1.t - k0.t);
const e = smoothstep(u);
return {
angle: lerpAngle(k0.angle, k1.angle, e),
offset: [lerp(k0.offset[0], k1.offset[0], e), lerp(k0.offset[1], k1.offset[1], e)]
};
}
}
return { angle: last.angle, offset: last.offset };
}
}
function buildSkeleton(imgs) {
const pelvis = new Bone('pelvis', imgs.pelvis, null, [0, 0], 0, [28, 10], 4);
const torso = new Bone('torso', imgs.torso, pelvis, [0, 0], 0, [31, 84], 5);
new Bone('head', imgs.head, torso, [0, -88], 0, [23, 42], 6);
const uaL = new Bone('upper_arm_l', imgs.upper_arm, torso, [-14, -74], 0, [12, 8], 1);
const laL = new Bone('lower_arm_l', imgs.lower_arm, uaL, [0, 62], 0, [11, 8], 1);
new Bone('hand_l', imgs.hand, laL, [0, 56], 0, [10, 10], 1);
const uaR = new Bone('upper_arm_r', imgs.upper_arm, torso, [14, -74], 0, [12, 8], 8);
const laR = new Bone('lower_arm_r', imgs.lower_arm, uaR, [0, 62], 0, [11, 8], 8);
new Bone('hand_r', imgs.hand, laR, [0, 56], 0, [10, 10], 8);
const thL = new Bone('thigh_l', imgs.thigh, pelvis, [-10, 4], 0, [15, 8], 2);
const shL = new Bone('shin_l', imgs.shin, thL, [0, 72], 0, [12, 8], 2);
new Bone('foot_l', imgs.foot, shL, [0, 66], 0, [9, 9], 2);
const thR = new Bone('thigh_r', imgs.thigh, pelvis, [10, 4], 0, [15, 8], 7);
const shR = new Bone('shin_r', imgs.shin, thR, [0, 72], 0, [12, 8], 7);
new Bone('foot_r', imgs.foot, shR, [0, 66], 0, [9, 9], 7);
return new Skeleton(pelvis);
}
function buildAnims() {
const A = {};
// idle
const idle = new Animation('idle', 2.0, true);
idle.addKey('pelvis', 0, 0, [0, 0]); idle.addKey('pelvis', 1.0, 0, [0, 3]); idle.addKey('pelvis', 2.0, 0, [0, 0]);
idle.addKey('torso', 0, 0); idle.addKey('torso', 1.0, 2); idle.addKey('torso', 2.0, 0);
idle.addKey('head', 0, 0); idle.addKey('head', 1.0, -2); idle.addKey('head', 2.0, 0);
idle.addKey('upper_arm_l', 0, 12); idle.addKey('upper_arm_l', 1.0, 16); idle.addKey('upper_arm_l', 2.0, 12);
idle.addKey('upper_arm_r', 0, -12); idle.addKey('upper_arm_r', 1.0, -16); idle.addKey('upper_arm_r', 2.0, -12);
idle.addKey('lower_arm_l', 0, -15); idle.addKey('lower_arm_l', 2.0, -15);
idle.addKey('lower_arm_r', 0, -15); idle.addKey('lower_arm_r', 2.0, -15);
A.idle = idle;
// run
const run = new Animation('run', 0.62, true);
const h = 0.31;
run.addKey('thigh_l', 0, -35); run.addKey('thigh_l', h, 30); run.addKey('thigh_l', 0.62, -35);
run.addKey('shin_l', 0, 45); run.addKey('shin_l', h, 5); run.addKey('shin_l', 0.62, 45);
run.addKey('thigh_r', 0, 30); run.addKey('thigh_r', h, -35); run.addKey('thigh_r', 0.62, 30);
run.addKey('shin_r', 0, 5); run.addKey('shin_r', h, 45); run.addKey('shin_r', 0.62, 5);
run.addKey('upper_arm_l', 0, -50); run.addKey('upper_arm_l', h, 45); run.addKey('upper_arm_l', 0.62, -50);
run.addKey('upper_arm_r', 0, 45); run.addKey('upper_arm_r', h, -50); run.addKey('upper_arm_r', 0.62, 45);
run.addKey('lower_arm_l', 0, -60); run.addKey('lower_arm_l', 0.62, -60);
run.addKey('lower_arm_r', 0, -60); run.addKey('lower_arm_r', 0.62, -60);
run.addKey('pelvis', 0, 0, [0, 0]); run.addKey('pelvis', h, 0, [0, -6]); run.addKey('pelvis', 0.62, 0, [0, 0]);
run.addKey('torso', 0, -5); run.addKey('torso', 0.62, -5);
A.run = run;
// dribble
const dr = new Animation('dribble', 0.55, true);
dr.addKey('pelvis', 0, 0, [0, 0]); dr.addKey('pelvis', 0.27, 0, [0, -3]); dr.addKey('pelvis', 0.55, 0, [0, 0]);
dr.addKey('torso', 0, 6); dr.addKey('torso', 0.27, 10); dr.addKey('torso', 0.55, 6);
dr.addKey('upper_arm_r', 0, 12); dr.addKey('upper_arm_r', 0.27, 22); dr.addKey('upper_arm_r', 0.55, 12);
dr.addKey('lower_arm_r', 0, -35); dr.addKey('lower_arm_r', 0.27, -50); dr.addKey('lower_arm_r', 0.55, -35);
dr.addKey('upper_arm_l', 0, -18); dr.addKey('upper_arm_l', 0.55, -18);
dr.addKey('lower_arm_l', 0, -25); dr.addKey('lower_arm_l', 0.55, -25);
dr.addKey('thigh_l', 0, -8); dr.addKey('thigh_l', 0.55, -8);
dr.addKey('thigh_r', 0, 12); dr.addKey('thigh_r', 0.55, 12);
dr.addKey('shin_l', 0, 18); dr.addKey('shin_l', 0.55, 18);
dr.addKey('shin_r', 0, 22); dr.addKey('shin_r', 0.55, 22);
A.dribble = dr;
// shoot
const sh = new Animation('shoot', 0.95, false);
sh.addKey('pelvis', 0.0, 0, [0, 0]); sh.addKey('pelvis', 0.18, 0, [0, 16]);
sh.addKey('pelvis', 0.42, 0, [0, -55]); sh.addKey('pelvis', 0.55, 0, [0, -62]);
sh.addKey('pelvis', 0.75, 0, [0, 10]); sh.addKey('pelvis', 1.0, 0, [0, 0]);
sh.addKey('torso', 0.0, 0); sh.addKey('torso', 0.18, 10); sh.addKey('torso', 0.42, -6);
sh.addKey('torso', 0.55, -4); sh.addKey('torso', 0.75, 6); sh.addKey('torso', 1.0, 0);
sh.addKey('head', 0.0, 0); sh.addKey('head', 0.42, -8); sh.addKey('head', 1.0, 0);
for (const s of ['l', 'r']) {
sh.addKey('thigh_' + s, 0.0, 0); sh.addKey('thigh_' + s, 0.18, -28);
sh.addKey('thigh_' + s, 0.42, -10); sh.addKey('thigh_' + s, 0.55, 5);
sh.addKey('thigh_' + s, 0.75, -20); sh.addKey('thigh_' + s, 1.0, 0);
sh.addKey('shin_' + s, 0.0, 0); sh.addKey('shin_' + s, 0.18, 50);
sh.addKey('shin_' + s, 0.42, 15); sh.addKey('shin_' + s, 0.55, 0);
sh.addKey('shin_' + s, 0.75, 35); sh.addKey('shin_' + s, 1.0, 0);
}
sh.addKey('upper_arm_r', 0.0, 0); sh.addKey('upper_arm_r', 0.18, 20);
sh.addKey('upper_arm_r', 0.42, -140); sh.addKey('upper_arm_r', 0.55, -160);
sh.addKey('upper_arm_r', 0.75, -40); sh.addKey('upper_arm_r', 1.0, 0);
sh.addKey('lower_arm_r', 0.0, 0); sh.addKey('lower_arm_r', 0.18, -30);
sh.addKey('lower_arm_r', 0.42, 60); sh.addKey('lower_arm_r', 0.55, 20);
sh.addKey('lower_arm_r', 0.75, -20); sh.addKey('lower_arm_r', 1.0, 0);
sh.addKey('upper_arm_l', 0.0, 0); sh.addKey('upper_arm_l', 0.18, -20);
sh.addKey('upper_arm_l', 0.42, -120); sh.addKey('upper_arm_l', 0.55, -130);
sh.addKey('upper_arm_l', 0.75, -30); sh.addKey('upper_arm_l', 1.0, 0);
sh.addKey('lower_arm_l', 0.0, 0); sh.addKey('lower_arm_l', 0.42, 50);
sh.addKey('lower_arm_l', 0.55, 30); sh.addKey('lower_arm_l', 1.0, 0);
A.shoot = sh;
// dunk
const dk = new Animation('dunk', 1.1, false);
dk.addKey('pelvis', 0.0, 0, [0, 0]); dk.addKey('pelvis', 0.2, 0, [0, -8]);
dk.addKey('pelvis', 0.45, 0, [0, -12]); dk.addKey('pelvis', 0.7, 0, [0, -4]);
dk.addKey('pelvis', 1.0, 0, [0, 0]);
dk.addKey('torso', 0.0, 0); dk.addKey('torso', 0.2, -8); dk.addKey('torso', 0.5, -14);
dk.addKey('torso', 0.8, 8); dk.addKey('torso', 1.0, 0);
for (const s of ['l', 'r']) {
dk.addKey('thigh_' + s, 0.0, 0); dk.addKey('thigh_' + s, 0.2, -50);
dk.addKey('thigh_' + s, 0.45, -35); dk.addKey('thigh_' + s, 0.7, -15);
dk.addKey('thigh_' + s, 1.0, 0);
dk.addKey('shin_' + s, 0.0, 0); dk.addKey('shin_' + s, 0.2, 70);
dk.addKey('shin_' + s, 0.45, 55); dk.addKey('shin_' + s, 0.7, 25);
dk.addKey('shin_' + s, 1.0, 0);
}
dk.addKey('upper_arm_r', 0.0, 0); dk.addKey('upper_arm_r', 0.2, 40);
dk.addKey('upper_arm_r', 0.45, -170); dk.addKey('upper_arm_r', 0.62, -185);
dk.addKey('upper_arm_r', 0.85, -60); dk.addKey('upper_arm_r', 1.0, 0);
dk.addKey('lower_arm_r', 0.0, 0); dk.addKey('lower_arm_r', 0.45, 30);
dk.addKey('lower_arm_r', 0.62, 10); dk.addKey('lower_arm_r', 1.0, 0);
dk.addKey('upper_arm_l', 0.0, 0); dk.addKey('upper_arm_l', 0.45, -80);
dk.addKey('upper_arm_l', 0.62, -90); dk.addKey('upper_arm_l', 1.0, 0);
A.dunk = dk;
// steal
const st = new Animation('steal', 0.5, false);
st.addKey('pelvis', 0.0, 0, [0, 0]);
st.addKey('pelvis', 0.15, 0, [0, 6]);
st.addKey('pelvis', 0.35, 0, [0, -2]);
st.addKey('pelvis', 0.5, 0, [0, 0]);
st.addKey('torso', 0.0, 0);
st.addKey('torso', 0.15, 20);
st.addKey('torso', 0.35, 14);
st.addKey('torso', 0.5, 0);
st.addKey('head', 0.0, 0);
st.addKey('head', 0.15, 6);
st.addKey('head', 0.5, 0);
st.addKey('upper_arm_r', 0.0, -12);
st.addKey('upper_arm_r', 0.15, -80);
st.addKey('upper_arm_r', 0.35, -100);
st.addKey('upper_arm_r', 0.5, -12);
st.addKey('lower_arm_r', 0.0, -15);
st.addKey('lower_arm_r', 0.15, -5);
st.addKey('lower_arm_r', 0.35, 0);
st.addKey('lower_arm_r', 0.5, -15);
st.addKey('upper_arm_l', 0.0, 12);
st.addKey('upper_arm_l', 0.15, -55);
st.addKey('upper_arm_l', 0.35, -70);
st.addKey('upper_arm_l', 0.5, 12);
st.addKey('lower_arm_l', 0.0, -15);
st.addKey('lower_arm_l', 0.5, -15);
st.addKey('thigh_l', 0.0, 0);
st.addKey('thigh_l', 0.15, -20);
st.addKey('thigh_l', 0.5, 0);
st.addKey('thigh_r', 0.0, 0);
st.addKey('thigh_r', 0.15, -20);
st.addKey('thigh_r', 0.5, 0);
st.addKey('shin_l', 0.0, 0);
st.addKey('shin_l', 0.15, 28);
st.addKey('shin_l', 0.5, 0);
st.addKey('shin_r', 0.0, 0);
st.addKey('shin_r', 0.15, 28);
st.addKey('shin_r', 0.5, 0);
A.steal = st;
return A;
}
// ============================================================
// 球员
// ============================================================
class Player {
constructor(cx, cz, imgs, hoop, isAI) {
this.cx = cx; this.cz = cz; this.cy = 0;
this.vx = 0; this.vz = 0; this.vy = 0;
this.facing = isAI ? -1 : 1;
this.hasBall = false;
this.isAI = isAI;
this.hoop = hoop;
this.sk = buildSkeleton(imgs);
this.anims = buildAnims();
this.animName = 'idle';
this.animTime = 0;
this.dribblePhase = 0;
this.charging = false;
this.charge = 0;
this.maxCharge = 1.1;
this.shotPower = 0;
this.shotPending = false;
this.shotFired = false;
this.jumping = false;
this.jumpCooldown = 0;
this.stealCooldown = 0;
this.spacePrev = false; // 空格(跳跃)边沿检测
this.shiftPrev = false; // Shift(投篮)边沿检测
this.isDunking = false;
this.dunkScored = false;
this.aiDecisionTimer = 0;
this.aiShootCharge = 0;
this.stealPending = false;
this.stealTarget = null;
}
play(name, force) {
if (this.animName === name && !force) return;
this.animName = name;
this.animTime = 0;
}
get anim() { return this.anims[this.animName] || this.anims.idle; }
jump() {
if (this.jumping || this.jumpCooldown > 0) return;
this.jumping = true;
this.vy = 780;
}
thinkHuman(dt, keys) {
let mx = 0, mz = 0;
if (keys['a'] || keys['arrowleft']) mx -= 1;
if (keys['d'] || keys['arrowright']) mx += 1;
if (keys['w'] || keys['arrowup']) mz += 1;
if (keys['s'] || keys['arrowdown']) mz -= 1;
const len = Math.hypot(mx, mz) || 1;
mx /= len; mz /= len;
const speed = this.hasBall ? 240 : 290;
if (this.charging || this.animName === 'shoot' || this.animName === 'dunk' || this.animName === 'steal') {
this.vx *= 0.82; this.vz *= 0.82;
} else {
this.vx = mx * speed;
this.vz = mz * speed;
}
if (mx !== 0) this.facing = mx > 0 ? 1 : -1;
// ==== 跳跃(空格) ====
const spaceNow = !!keys[' '];
if (spaceNow && !this.spacePrev) this.jump();
this.spacePrev = spaceNow;
// ==== 抢断(E) ====
if (keys['e'] && this.stealCooldown <= 0 && !this.hasBall && this.animName !== 'steal') {
this.stealCooldown = 0.7;
this.play('steal', true);
this.stealPending = true;
this.stealTarget = this.isAI ? player : ai;
}
// ==== 投篮 / 扣篮(Shift) ====
const shiftNow = !!keys['shift'];
const pressed = shiftNow && !this.shiftPrev;
const released = !shiftNow && this.shiftPrev;
this.shiftPrev = shiftNow;
if (this.hasBall && this.animName !== 'shoot' && this.animName !== 'dunk' && this.animName !== 'steal') {
const d = dist2D(this.cx, this.cz, this.hoop.cx, this.hoop.cz);
if (pressed && this.jumping && d < 220 && this.cy > 40) {
this.play('dunk', true);
this.charging = false;
this.isDunking = true;
this.dunkScored = false;
ball.state = 'dunk';
} else if (pressed) {
this.charging = true;
this.charge = 0;
}
if (this.charging) {
this.charge += dt;
if (released) {
this.charging = false;
this.shotPower = clamp(this.charge / this.maxCharge, 0, 1);
this.play('shoot', true);
this.shotPending = true;
}
}
} else if (!this.hasBall) {
this.charging = false;
}
if (this.animName !== 'shoot' && this.animName !== 'dunk' && this.animName !== 'steal') {
const moving = Math.hypot(this.vx, this.vz) > 25;
if (this.charging) this.play('idle');
else if (moving) this.play('run');
else if (this.hasBall) this.play('dribble');
else this.play('idle');
}
}
thinkAI(dt) {
this.aiDecisionTimer -= dt;
if (this.stealCooldown > 0) this.stealCooldown -= dt;
const opp = this.isAI ? player : ai;
const speed = this.hasBall ? 250 : 285;
if (this.hasBall) {
const hoop = this.hoop;
const dx = hoop.cx - this.cx;
const dz = hoop.cz - this.cz;
const d = Math.hypot(dx, dz);
this.facing = dx > 0 ? 1 : -1;
if (d < 200 && this.jumping && this.cy > 40 && !this.isDunking) {
this.play('dunk', true);
this.isDunking = true;
this.dunkScored = false;
ball.state = 'dunk';
this.vx *= 0.5; this.vz *= 0.5;
return;
}
if (d < 260) {
this.vx *= 0.85; this.vz *= 0.85;
if (!this.jumping && this.jumpCooldown <= 0 && d < 190 && Math.random() < 0.5) {
this.jump();
this.jumpCooldown = 1.4;
}
if (!this.jumping) {
if (!this.charging) { this.charging = true; this.charge = 0; }
this.charge += dt;
this.aiShootCharge = 0.75 + (Math.random() - 0.5) * 0.3;
if (this.charge > this.aiShootCharge * this.maxCharge) {
this.charging = false;
this.shotPower = clamp(this.charge / this.maxCharge, 0, 1);
this.play('shoot', true);
this.shotPending = true;
} else {
this.play('idle');
}
} else {
this.play('idle');
}
} else {
this.charging = false; this.charge = 0;
this.vx = (dx / d) * speed;
this.vz = (dz / d) * speed;
this.play('run');
}
return;
}
if (opp.hasBall) {
this.charging = false; this.charge = 0;
const targetX = opp.cx + opp.facing * 25;
const targetZ = opp.cz;
const dx = targetX - this.cx;
const dz = targetZ - this.cz;
const d = Math.hypot(dx, dz);
this.facing = opp.cx < this.cx ? -1 : 1;
if (this.animName !== 'steal') {
if (d > 25) {
this.vx = (dx / d) * speed;
this.vz = (dz / d) * speed;
this.play('run');
} else {
this.vx *= 0.8; this.vz *= 0.8;
this.play('idle');
}
} else {
this.vx *= 0.85; this.vz *= 0.85;
}
if (this.stealCooldown <= 0
&& dist2D(this.cx, this.cz, opp.cx, opp.cz) < 70
&& this.animName !== 'steal') {
this.stealCooldown = 0.9;
this.play('steal', true);
this.stealPending = true;
this.stealTarget = opp;
}
return;
}
this.charging = false; this.charge = 0;
const dx = ball.cx - this.cx;
const dz = ball.cz - this.cz;
const d = Math.hypot(dx, dz) || 1;
this.facing = dx < 0 ? -1 : 1;
this.vx = (dx / d) * speed;
this.vz = (dz / d) * speed;
this.play('run');
}
update(dt, keys) {
if (this.jumpCooldown > 0) this.jumpCooldown -= dt;
if (this.stealCooldown > 0 && this.animName !== 'steal') this.stealCooldown -= dt;
const inAction = this.animName === 'shoot'
|| this.animName === 'dunk'
|| this.animName === 'steal';
if (inAction) {
this.animTime += dt;
this.vx *= 0.86; this.vz *= 0.86;
if (this.animTime >= this.anim.duration) {
this.animName = 'idle'; this.animTime = 0;
this.isDunking = false;
}
} else {
if (this.isAI) this.thinkAI(dt);
else this.thinkHuman(dt, keys);
this.animTime += dt;
}
this.cx += this.vx * dt;
this.cz += this.vz * dt;
this.cx = clamp(this.cx, 60, COURT_LEN - 60);
this.cz = clamp(this.cz, 60, COURT_DEPTH - 60);
if (this.jumping) {
this.vy -= GRAVITY * dt;
this.cy += this.vy * dt;
if (this.cy <= 0) { this.cy = 0; this.vy = 0; this.jumping = false; this.isDunking = false; }
}
if (this.hasBall && !inAction) {
const moving = Math.hypot(this.vx, this.vz) > 25;
this.dribblePhase += dt * (moving ? 9 : 7);
}
const pose = this.anim.sample(this.animTime);
for (const b of Object.values(this.sk.bones)) b.resetPose();
for (const [name, p] of Object.entries(pose)) {
const b = this.sk.bones[name];
if (b) { b.animAngle = p.angle; b.animOffset.x = p.offset[0]; b.animOffset.y = p.offset[1]; }
}
// 出手判定:必须仍在投篮动画中,否则不触发(防止被抢断后仍然投出)
if (this.shotPending && this.animName === 'shoot' && this.animTime >= 0.48) {
this.shotPending = false;
this.shotFired = true;
}
// 抢断判定(含空中抢扣篮球)
if (this.stealPending && this.animName === 'steal' && this.animTime >= 0.16) {
this.stealPending = false;
const opp = this.stealTarget;
if (opp && opp.hasBall && dist2D(this.cx, this.cz, opp.cx, opp.cz) < 100) {
const oppDunking = opp.animName === 'dunk';
let chance = this.isAI ? 0.45 : 0.65;
if (oppDunking) chance = this.isAI ? 0.55 : 0.8;
if (Math.random() < chance) {
opp.hasBall = false;
this.hasBall = true;
ball.owner = this;
ball.state = 'dribble';
// 关键:清除对方的投篮状态,防止抢断后球仍被投出
opp.shotPending = false;
opp.shotFired = false;
opp.charging = false;
opp.charge = 0;
if (opp.animName === 'shoot' || opp.animName === 'dunk') {
opp.animName = 'idle'; opp.animTime = 0;
opp.isDunking = false;
opp.dunkScored = true;
}
opp.stealPending = false;
if (oppDunking) {
msg(this.isAI ? '电脑空中抢断!' : '空中抢断!', '#f39c12');
} else {
msg(this.isAI ? '电脑抢断!' : '抢断成功!', this.isAI ? '#e67e22' : '#f39c12');
}
}
}
this.stealTarget = null;
}
}
draw(g) {
const s = w2s(this.cx, this.cz, this.cy);
if (this.animName === 'steal' && this.animTime < 0.35) {
const t = this.animTime / 0.35;
const r = 28 + t * 55;
g.save();
g.strokeStyle = `rgba(243,156,18,${(1 - t) * 0.9})`;
g.lineWidth = 4 * s.scale;
g.beginPath();
g.ellipse(s.x, s.y - 40 * s.scale, r * s.scale, r * s.scale * 0.5, 0, 0, Math.PI * 2);
g.stroke();
g.restore();
}
g.save();
g.translate(s.x, s.y);
g.scale(s.scale * PLAYER_SCALE, s.scale * PLAYER_SCALE);
this.sk.update({ x: 0, y: -138 }, this.facing < 0);
this.sk.draw(g);
g.restore();
this._screenInfo = s;
}
handScreen() {
const s = this._screenInfo || w2s(this.cx, this.cz, this.cy);
const hand = this.sk.bones['hand_r'].worldPos;
return { x: s.x + hand.x * s.scale * PLAYER_SCALE, y: s.y + hand.y * s.scale * PLAYER_SCALE };
}
}
// ============================================================
// 篮球
// ============================================================
const ball = {
cx: 900, cz: 450, cy: 30,
vx: 0, vy: 0, vz: 0,
owner: null,
state: 'loose',
lastShooter: null,
scoredThisShot: false,
pickupCooldown: 0,
resetTimer: 0,
spin: 0,
prevCy: 30,
attachToOwner(p) {
const s = w2s(p.cx, p.cz, 0);
const hand = p.sk.bones['hand_r'].worldPos;
this.cx = p.cx + p.facing * (hand.x * PLAYER_SCALE * 0.55);
this.cz = p.cz + 18;
if (p.animName === 'dribble' || p.animName === 'idle' || p.animName === 'run') {
const bounce = Math.abs(Math.cos(p.dribblePhase));
this.cy = p.cy + 18 + bounce * 72;
} else if (p.animName === 'shoot') {
const t = p.animTime / p.anim.duration;
this.cy = p.cy + 70 + Math.sin(t * Math.PI) * 90;
} else if (p.animName === 'dunk') {
this.cy = p.cy + 130;
} else {
this.cy = p.cy + 70;
}
},
update(dt) {
if (this.pickupCooldown > 0) this.pickupCooldown -= dt;
if (this.owner) {
this.attachToOwner(this.owner);
this.prevCy = this.cy;
return;
}
this.prevCy = this.cy;
this.vy -= GRAVITY * dt;
this.cx += this.vx * dt;
this.cz += this.vz * dt;
this.cy += this.vy * dt;
this.spin += this.vx * dt * 0.5;
if (this.cy < BALL_R_WORLD) {
this.cy = BALL_R_WORLD;
this.vy *= -0.6;
this.vx *= 0.85; this.vz *= 0.85;
if (Math.abs(this.vy) < 60) this.vy = 0;
}
if (this.cx < BALL_R_WORLD) { this.cx = BALL_R_WORLD; this.vx *= -0.7; }
if (this.cx > COURT_LEN - BALL_R_WORLD) { this.cx = COURT_LEN - BALL_R_WORLD; this.vx *= -0.7; }
if (this.cz < BALL_R_WORLD) { this.cz = BALL_R_WORLD; this.vz *= -0.7; }
if (this.cz > COURT_DEPTH - BALL_R_WORLD) { this.cz = COURT_DEPTH - BALL_R_WORLD; this.vz *= -0.7; }
this.checkScore(HOOP_L, 'ai');
this.checkScore(HOOP_R, 'player');
const still = this.vy === 0 && Math.abs(this.vx) < 30 && Math.abs(this.vz) < 30 && this.cy <= BALL_R_WORLD + 1;
if (still) {
this.resetTimer += dt;
if (this.resetTimer > 2.5) {
this.cx = COURT_LEN / 2; this.cz = COURT_DEPTH / 2; this.cy = 30;
this.resetTimer = 0;
}
} else this.resetTimer = 0;
},
checkScore(hoop, team) {
if (this.scoredThisShot) return;
const dx = this.cx - hoop.cx;
const dz = this.cz - hoop.cz;
if (Math.hypot(dx, dz) > hoop.r) return;
if (this.prevCy > hoop.cy && this.cy <= hoop.cy && this.vy < 0) {
this.scoredThisShot = true;
this.vy *= 0.3;
this.vx *= 0.2; this.vz *= 0.2;
onScore(team, 1);
}
},
draw(g) {
const s = w2s(this.cx, this.cz, this.cy);
const r = BALL_R_WORLD * s.scale * 1.05;
g.save();
g.translate(s.x, s.y);
g.rotate(this.spin * Math.PI / 180);
g.drawImage(BALL_IMG, -r, -r, r * 2, r * 2);
g.restore();
}
};
// ============================================================
// 游戏状态
// ============================================================
const player = new Player(COURT_LEN * 0.4, COURT_DEPTH * 0.6, IMGS_P, HOOP_R, false);
const ai = new Player(COURT_LEN * 0.6, COURT_DEPTH * 0.4, IMGS_A, HOOP_L, true);
let playerScore = 0, aiScore = 0;
let message = '', messageColor = '#f1c40f', messageTimer = 0;
const keys = {};
let roundResetTimer = 0;
let pendingBallOwner = null;
// 扣篮特效数组
const dunkEffects = [];
function msg(text, color) {
message = text; messageColor = color || '#f1c40f'; messageTimer = 1.6;
}
function onScore(team, points) {
points = points || 1;
if (team === 'player') {
playerScore += points;
msg(points > 1 ? '玩家扣篮!+2' : '玩家得分!', '#5b8def');
pendingBallOwner = ai;
} else {
aiScore += points;
msg(points > 1 ? '电脑扣篮!+2' : '电脑得分!', '#e74c3c');
pendingBallOwner = player;
}
roundResetTimer = 1.3;
}
function resetRound() {
player.cx = COURT_LEN * 0.4;
player.cz = COURT_DEPTH * 0.6;
player.cy = 0; player.vx = 0; player.vz = 0; player.vy = 0;
player.jumping = false;
player.animName = 'idle'; player.animTime = 0;
player.charging = false; player.charge = 0;
player.shotPending = false; player.shotFired = false;
player.isDunking = false; player.dunkScored = false;
player.hasBall = false;
player.stealCooldown = 0;
player.stealPending = false; player.stealTarget = null;
ai.cx = COURT_LEN * 0.6;
ai.cz = COURT_DEPTH * 0.4;
ai.cy = 0; ai.vx = 0; ai.vz = 0; ai.vy = 0;
ai.jumping = false;
ai.animName = 'idle'; ai.animTime = 0;
ai.charging = false; ai.charge = 0;
ai.shotPending = false; ai.shotFired = false;
ai.isDunking = false; ai.dunkScored = false;
ai.hasBall = false;
ai.stealCooldown = 0;
ai.stealPending = false; ai.stealTarget = null;
ball.cx = COURT_LEN / 2;
ball.cz = COURT_DEPTH / 2;
ball.cy = 30;
ball.vx = 0; ball.vy = 0; ball.vz = 0;
ball.prevCy = 30;
ball.scoredThisShot = false;
ball.pickupCooldown = 0;
ball.resetTimer = 0;
ball.spin = 0;
ball.owner = null;
ball.state = 'loose';
const owner = pendingBallOwner || player;
owner.hasBall = true;
owner.dribblePhase = 0;
ball.owner = owner;
ball.state = 'dribble';
pendingBallOwner = null;
}
player.hasBall = true;
ball.owner = player;
ball.state = 'dribble';
// ============================================================
// 抢断 / 拾取 / 碰撞
// ============================================================
function tryPickup() {
if (ball.owner) return;
if (ball.pickupCooldown > 0) return;
for (const p of [player, ai]) {
const d = dist2D(p.cx, p.cz, ball.cx, ball.cz);
if (d < 62 && ball.cy < 130) {
p.hasBall = true;
ball.owner = p;
ball.state = 'dribble';
ball.pickupCooldown = 0.3;
ball.scoredThisShot = false;
p.dribblePhase = 0;
break;
}
}
}
function resolveCollision() {
const dx = player.cx - ai.cx;
const dz = player.cz - ai.cz;
const d = Math.hypot(dx, dz);
const MIN = 62;
if (d > 0 && d < MIN) {
const push = (MIN - d) / 2;
const nx = dx / d, nz = dz / d;
player.cx = clamp(player.cx + nx * push, 60, COURT_LEN - 60);
player.cz = clamp(player.cz + nz * push, 60, COURT_DEPTH - 60);
ai.cx = clamp(ai.cx - nx * push, 60, COURT_LEN - 60);
ai.cz = clamp(ai.cz - nz * push, 60, COURT_DEPTH - 60);
}
}
function tryDunk() {
for (const p of [player, ai]) {
if (p.animName !== 'dunk' || !p.hasBall) continue;
if (p.dunkScored) continue;
const t = p.animTime / p.anim.duration;
if (t > 0.45 && t < 0.75) {
const hoop = p.hoop;
const d = dist2D(p.cx, p.cz, hoop.cx, hoop.cz);
if (d < 260) {
p.dunkScored = true;
p.hasBall = false;
ball.owner = null;
ball.cx = hoop.cx;
ball.cz = hoop.cz;
ball.cy = hoop.cy + 10;
ball.vx = 0; ball.vz = 0; ball.vy = -400;
ball.scoredThisShot = true;
ball.pickupCooldown = 0.5;
ball.resetTimer = 0.6;
dunkEffects.push({
x: hoop.cx, z: hoop.cz, y: hoop.cy,
time: 0, maxTime: 0.8,
team: p === player ? 'player' : 'ai'
});
onScore(p === player ? 'player' : 'ai', 2);
}
}
}
}
// ============================================================
// 投篮出手(蓄力时机决定命中率)
// ============================================================
function shootBall(shooter, hoop) {
const from = { x: shooter.cx + shooter.facing * 40, y: shooter.cy + 95, z: shooter.cz + 15 };
const power = shooter.shotPower;
// 蓄力越接近 0.8 越准,偏离越多误差越大
const errBase = Math.pow(Math.abs(power - 0.8) * 2.5, 2) * 280;
const target = {
x: hoop.cx + (Math.random() - 0.5) * errBase,
z: hoop.cz + (Math.random() - 0.5) * errBase * 0.6,
y: hoop.cy + 5
};
const flightTime = lerp(0.78, 1.18, power);
ball.owner = null;
ball.state = 'flying';
ball.cx = from.x; ball.cy = from.y; ball.cz = from.z;
ball.prevCy = from.y;
ball.vx = (target.x - from.x) / flightTime;
ball.vz = (target.z - from.z) / flightTime;
ball.vy = (target.y - from.y) / flightTime + 0.5 * GRAVITY * flightTime;
ball.scoredThisShot = false;
ball.pickupCooldown = 0.35;
shooter.hasBall = false;
}
// ============================================================
// 球场渲染
// ============================================================
function drawCourt(g) {
const bg = g.createLinearGradient(0, 0, 0, H);
bg.addColorStop(0, '#08091a');
bg.addColorStop(0.4, '#12162e');
bg.addColorStop(1, '#05060a');
g.fillStyle = bg;
g.fillRect(0, 0, W, H);
for (let i = 0; i < 180; i++) {
const x = (i * 197) % W;
const y = 40 + ((i * 113) % 220);
g.fillStyle = `rgba(255,220,150,${0.04 + (i % 7) * 0.018})`;
g.beginPath();
g.arc(x, y, 1.5 + (i % 3), 0, Math.PI * 2);
g.fill();
}
const p1 = w2s(0, 0);
const p2 = w2s(COURT_LEN, 0);
const p3 = w2s(COURT_LEN, COURT_DEPTH);
const p4 = w2s(0, COURT_DEPTH);
const floorGrad = g.createLinearGradient(0, p4.y, 0, p1.y);
floorGrad.addColorStop(0, '#7a5535');
floorGrad.addColorStop(1, '#a8794c');
g.fillStyle = floorGrad;
g.beginPath();
g.moveTo(p1.x, p1.y);
g.lineTo(p2.x, p2.y);
g.lineTo(p3.x, p3.y);
g.lineTo(p4.x, p4.y);
g.closePath();
g.fill();
g.strokeStyle = 'rgba(0,0,0,0.10)';
g.lineWidth = 1;
for (let i = 0; i <= 24; i++) {
const cx = (i / 24) * COURT_LEN;
const a = w2s(cx, 0);
const b = w2s(cx, COURT_DEPTH);
g.beginPath();
g.moveTo(a.x, a.y);
g.lineTo(b.x, b.y);
g.stroke();
}
for (let i = 0; i <= 12; i++) {
const cz = (i / 12) * COURT_DEPTH;
const a = w2s(0, cz);
const b = w2s(COURT_LEN, cz);
g.beginPath();
g.moveTo(a.x, a.y);
g.lineTo(b.x, b.y);
g.stroke();
}
g.strokeStyle = 'rgba(255,255,255,0.7)';
g.lineWidth = 3;
g.beginPath();
g.moveTo(p1.x, p1.y);
g.lineTo(p2.x, p2.y);
g.lineTo(p3.x, p3.y);
g.lineTo(p4.x, p4.y);
g.closePath();
g.stroke();
const ml = w2s(COURT_LEN / 2, 0);
const mr = w2s(COURT_LEN / 2, COURT_DEPTH);
g.beginPath();
g.moveTo(ml.x, ml.y);
g.lineTo(mr.x, mr.y);
g.stroke();
g.beginPath();
const segments = 48;
for (let i = 0; i <= segments; i++) {
const a = (i / segments) * Math.PI * 2;
const cx = COURT_LEN / 2 + Math.cos(a) * 160;
const cz = COURT_DEPTH / 2 + Math.sin(a) * 160;
const p = w2s(cx, cz);
if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y);
}
g.stroke();
g.strokeStyle = 'rgba(255,255,255,0.55)';
g.lineWidth = 2.5;
drawPaint(g, 0);
drawPaint(g, COURT_LEN);
}
function drawPaint(g, baseX) {
const dir = baseX === 0 ? 1 : -1;
const pts = [
w2s(baseX, COURT_DEPTH / 2 - 130),
w2s(baseX + dir * 280, COURT_DEPTH / 2 - 130),
w2s(baseX + dir * 280, COURT_DEPTH / 2 + 130),
w2s(baseX, COURT_DEPTH / 2 + 130),
];
g.beginPath();
g.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) g.lineTo(pts[i].x, pts[i].y);
g.closePath();
g.stroke();
g.beginPath();
const segs = 40;
for (let i = 0; i <= segs; i++) {
const a = (i / segs) * Math.PI * 2;
const cx = baseX + dir * 280 + Math.cos(a) * 95;
const cz = COURT_DEPTH / 2 + Math.sin(a) * 95;
const p = w2s(cx, cz);
if (i === 0) g.moveTo(p.x, p.y); else g.lineTo(p.x, p.y);
}
g.stroke();
}
function drawHoopBack(g, hoop, isRight) {
const poleX = isRight ? hoop.cx + 100 : hoop.cx - 100;
const poleTop = w2s(poleX, hoop.cz, hoop.cy + 110);
const poleBot = w2s(poleX, hoop.cz, 0);
g.strokeStyle = '#2c2c3a';
g.lineWidth = 10 * poleTop.scale;
g.beginPath();
g.moveTo(poleTop.x, poleTop.y);
g.lineTo(poleBot.x, poleBot.y);
g.stroke();
const bx = hoop.back;
const top = w2s(bx, hoop.cz - 95, hoop.cy + 110);
const topR = w2s(bx, hoop.cz + 95, hoop.cy + 110);
const botR = w2s(bx, hoop.cz + 95, hoop.cy - 25);
const bot = w2s(bx, hoop.cz - 95, hoop.cy - 25);
g.fillStyle = 'rgba(230,240,250,0.92)';
g.beginPath();
g.moveTo(top.x, top.y);
g.lineTo(topR.x, topR.y);
g.lineTo(botR.x, botR.y);
g.lineTo(bot.x, bot.y);
g.closePath();
g.fill();
g.strokeStyle = '#7a8aa0';
g.lineWidth = 2.5;
g.stroke();
const iTop = w2s(bx, hoop.cz - 42, hoop.cy + 60);
const iTopR = w2s(bx, hoop.cz + 42, hoop.cy + 60);
const iBotR = w2s(bx, hoop.cz + 42, hoop.cy - 5);
const iBot = w2s(bx, hoop.cz - 42, hoop.cy - 5);
g.strokeStyle = '#d94f4f';
g.lineWidth = 3;
g.beginPath();
g.moveTo(iTop.x, iTop.y);
g.lineTo(iTopR.x, iTopR.y);
g.lineTo(iBotR.x, iBotR.y);
g.lineTo(iBot.x, iBot.y);
g.closePath();
g.stroke();
}
function drawHoopFront(g, hoop) {
const c = w2s(hoop.cx, hoop.cz, hoop.cy);
const rx = hoop.r * c.scale;
const ry = hoop.r * c.scale * 0.32;
g.strokeStyle = '#e74c3c';
g.lineWidth = 6 * c.scale;
g.beginPath();
g.ellipse(c.x, c.y, rx, ry, 0, 0, Math.PI * 2);
g.stroke();
g.strokeStyle = 'rgba(255,255,255,0.4)';
g.lineWidth = 2 * c.scale;
g.beginPath();
g.ellipse(c.x, c.y - 2, rx * 0.9, ry * 0.8, 0, Math.PI, Math.PI * 2);
g.stroke();
g.strokeStyle = 'rgba(255,255,255,0.65)';
g.lineWidth = 1.4;
const netH = 55 * c.scale;
const N = 8;
for (let i = 0; i <= N; i++) {
const t = i / N;
const a = t * Math.PI * 2;
const xTop = c.x + Math.cos(a) * rx;
const yTop = c.y + Math.sin(a) * ry;
const xBot = c.x + Math.cos(a) * rx * 0.5;
const yBot = c.y + netH;
g.beginPath();
g.moveTo(xTop, yTop);
g.lineTo(xBot, yBot);
g.stroke();
}
for (let j = 1; j <= 3; j++) {
const t = j / 4;
const rr = lerp(1, 0.5, t);
const yy = c.y + netH * t;
g.beginPath();
g.ellipse(c.x, yy, rx * rr, ry * rr, 0, 0, Math.PI * 2);
g.stroke();
}
}
// 绘制扣篮特效
function drawDunkEffects(g) {
for (const e of dunkEffects) {
const s = w2s(e.x, e.z, e.y);
const t = e.time / e.maxTime;
const alpha = (1 - t) * 0.9;
const baseR = 20 + t * 90;
g.save();
g.strokeStyle = `rgba(255, 200, 50, ${alpha})`;
g.lineWidth = 6 * (1 - t) + 2;
g.beginPath();
g.arc(s.x, s.y, baseR * s.scale, 0, Math.PI * 2);
g.stroke();
g.strokeStyle = `rgba(255, 90, 40, ${alpha * 0.7})`;
g.lineWidth = 4 * (1 - t) + 1;
g.beginPath();
g.arc(s.x, s.y, baseR * s.scale * 0.6, 0, Math.PI * 2);
g.stroke();
if (t < 0.5) {
const flashA = (1 - t * 2) * 0.8;
const grad = g.createRadialGradient(s.x, s.y, 0, s.x, s.y, 60 * s.scale);
grad.addColorStop(0, `rgba(255,255,220,${flashA})`);
grad.addColorStop(1, 'rgba(255,200,50,0)');
g.fillStyle = grad;
g.beginPath();
g.arc(s.x, s.y, 60 * s.scale, 0, Math.PI * 2);
g.fill();
}
g.restore();
}
}
// ============================================================
// HUD(中文化)
// ============================================================
function drawHUD(g) {
// 玩家
g.fillStyle = 'rgba(0,0,0,0.55)';
g.fillRect(28, 24, 200, 84);
g.fillStyle = '#5b8def';
g.font = 'bold 22px system-ui';
g.textAlign = 'left';
g.fillText('玩家', 48, 54);
g.fillStyle = '#fff';
g.font = 'bold 42px system-ui';
g.fillText(playerScore, 48, 96);
// 电脑
g.fillStyle = 'rgba(0,0,0,0.55)';
g.fillRect(W - 228, 24, 200, 84);
g.fillStyle = '#e74c3c';
g.font = 'bold 22px system-ui';
g.textAlign = 'right';
g.fillText('电脑', W - 48, 54);
g.fillStyle = '#fff';
g.font = 'bold 42px system-ui';
g.fillText(aiScore, W - 48, 96);
// 持球指示
if (ball.owner) {
const s = w2s(ball.owner.cx, ball.owner.cz, 0);
g.fillStyle = ball.owner === player ? 'rgba(91,141,239,0.9)' : 'rgba(231,76,60,0.9)';
g.beginPath();
g.arc(s.x, s.y + 8, 8, 0, Math.PI * 2);
g.fill();
g.strokeStyle = '#fff';
g.lineWidth = 2;
g.stroke();
}
// 蓄力条
if (player.charging) {
const bw = 260, bh = 18;
const bx = W / 2 - bw / 2, by = H - 78;
g.fillStyle = 'rgba(0,0,0,0.7)';
g.fillRect(bx - 4, by - 4, bw + 8, bh + 8);
const p = clamp(player.charge / player.maxCharge, 0, 1);
const grad = g.createLinearGradient(bx, 0, bx + bw, 0);
grad.addColorStop(0, '#2ecc71');
grad.addColorStop(0.6, '#f1c40f');
grad.addColorStop(1, '#e74c3c');
g.fillStyle = grad;
g.fillRect(bx, by, bw * p, bh);
g.strokeStyle = '#fff';
g.lineWidth = 2;
g.strokeRect(bx, by, bw, bh);
// 甜蜜点标记
g.fillStyle = 'rgba(255,255,255,0.95)';
g.fillRect(bx + bw * 0.8 - 2, by - 6, 4, bh + 12);
// 动态提示
let hintText = '蓄力中(松开 Shift 出手)';
let hintColor = 'rgba(255,255,255,0.85)';
if (p > 0.72 && p < 0.88) {
hintText = '★ 最佳时机!';
hintColor = '#2ecc71';
} else if (p < 0.6) {
hintText = '蓄力不足…';
hintColor = '#e67e22';
} else if (p > 0.95) {
hintText = '蓄力过猛…';
hintColor = '#e74c3c';
}
g.fillStyle = hintColor;
g.font = 'bold 16px system-ui';
g.textAlign = 'center';
g.fillText(hintText, W / 2, by - 12);
}
// 消息
if (messageTimer > 0) {
const a = clamp(messageTimer, 0, 1);
g.font = 'bold 58px system-ui';
g.textAlign = 'center';
g.shadowColor = messageColor;
g.shadowBlur = 24;
g.fillStyle = messageColor;
g.globalAlpha = a;
g.fillText(message, W / 2, 200);
g.globalAlpha = 1;
g.shadowBlur = 0;
}
// 底部提示
g.fillStyle = 'rgba(255,255,255,0.55)';
g.font = '15px system-ui';
g.textAlign = 'left';
g.fillText('WASD 移动 空格 跳跃 Shift 投篮 / 蓄力 近筐跳跃+Shift 扣篮 E 抢断 / 空中抢扣篮', 28, H - 22);
g.textAlign = 'right';
g.fillText('电脑也会运球 / 抢断 / 扣篮', W - 28, H - 22);
}
// ============================================================
// 渲染
// ============================================================
function render() {
ctx.clearRect(0, 0, W, H);
drawCourt(ctx);
drawHoopBack(ctx, HOOP_L, false);
drawHoopBack(ctx, HOOP_R, true);
const drawables = [
{ cz: player.cz, draw: () => player.draw(ctx) },
{ cz: ai.cz, draw: () => ai.draw(ctx) },
{ cz: ball.cz, draw: () => ball.draw(ctx) },
];
drawables.sort((a, b) => b.cz - a.cz);
for (const d of drawables) d.draw();
drawHoopFront(ctx, HOOP_L);
drawHoopFront(ctx, HOOP_R);
drawDunkEffects(ctx);
drawHUD(ctx);
}
// ============================================================
// 主循环
// ============================================================
let lastTime = performance.now();
function loop(now) {
const dt = Math.min((now - lastTime) / 1000, 0.05);
lastTime = now;
if (messageTimer > 0) messageTimer -= dt;
for (let i = dunkEffects.length - 1; i >= 0; i--) {
dunkEffects[i].time += dt;
if (dunkEffects[i].time > dunkEffects[i].maxTime) dunkEffects.splice(i, 1);
}
if (roundResetTimer > 0) {
roundResetTimer -= dt;
if (roundResetTimer <= 0) resetRound();
render();
requestAnimationFrame(loop);
return;
}
player.update(dt, keys);
ai.update(dt, keys);
// 出手:加 hasBall 双保险,防止被抢断后仍投出
if (player.shotFired) {
player.shotFired = false;
if (player.hasBall) shootBall(player, HOOP_R);
}
if (ai.shotFired) {
ai.shotFired = false;
if (ai.hasBall) shootBall(ai, HOOP_L);
}
tryDunk();
ball.update(dt);
tryPickup();
resolveCollision();
render();
requestAnimationFrame(loop);
}
// ============================================================
// 输入
// ============================================================
window.addEventListener('keydown', e => {
const k = e.key.toLowerCase();
keys[k] = true;
if ([' ', 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'shift'].includes(k)) {
e.preventDefault();
}
});
window.addEventListener('keyup', e => {
keys[e.key.toLowerCase()] = false;
});
requestAnimationFrame(loop);
})();
</script>
</body>
</html>