Download index.html from WACATW/NBAgame: direct link, hf CLI and curl.
- Browser
- Download file 47 kB
-
https://huggingface.co/spaces/WACATW/NBAgame/resolve/main/index.html
- Command line
-
hf download hf://spaces/WACATW/NBAgame/index.html
-
curl -L -o index.html https://huggingface.co/spaces/WACATW/NBAgame/resolve/main/index.html
47 kB
| <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> |