basketball.vue 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972
  1. <template>
  2. <div class="game-container">
  3. <canvas :id="'canvas' + type" @mousedown="handleMouseDown" @mouseup="handleMouseUp" @touchstart="handleTouchStart"
  4. @touchend="handleTouchEnd"></canvas>
  5. <canvas ref="canvasRef" :width="clientObj.width" :height="clientObj.height"
  6. style="position:absolute;left: 0; top: 0;"></canvas>
  7. </div>
  8. </template>
  9. <script setup name="Basketball" lang="ts">
  10. import { onMounted, ref, reactive, onBeforeUnmount } from 'vue';
  11. import { initSpeech, speckText, playMusic, controlMusic, speckCancel, chineseNumber } from '@/utils/speech';
  12. const { proxy } = getCurrentInstance() as any;
  13. const router = useRouter();
  14. const emit = defineEmits(['confirmExit']);
  15. const canvasRef = ref(null);
  16. //父值
  17. const props = defineProps({
  18. type: {
  19. type: String,
  20. default: false
  21. },
  22. });
  23. const data = reactive<any>({
  24. bodyposeData: {},//姿态信息
  25. clientObj: {},//浏览器对象
  26. boxes: [],//四个点坐标
  27. proportion: null,//人框和屏幕比例
  28. myThrow: 0,//0放下1举投
  29. direction: null,//跑动
  30. });
  31. const { bodyposeData, clientObj, boxes, proportion, myThrow, direction } = toRefs(data);
  32. /**
  33. * 监听投篮
  34. */
  35. watch(
  36. () => myThrow.value,
  37. (newData, oldData) => {
  38. if (oldData == undefined) {
  39. return false;
  40. }
  41. console.log("ppp", oldData, newData)
  42. if (newData == 1) {
  43. console.log("投篮")
  44. handleMouseDown();
  45. setTimeout(() => {
  46. handleMouseUp();
  47. }, 100)
  48. } else {
  49. }
  50. },
  51. { immediate: true }
  52. );
  53. /**
  54. * 监听跑动
  55. */
  56. watch(
  57. () => direction.value,
  58. (newData, oldData) => {
  59. nextTick(() => {
  60. if (newData > oldData) {
  61. gameState.keyRight = true;
  62. gameState.keyLeft = false;
  63. } else {
  64. gameState.keyLeft = true;
  65. gameState.keyRight = false;
  66. }
  67. });
  68. },
  69. { immediate: true }
  70. );
  71. /**
  72. * 初始化
  73. */
  74. const getInit = (e: any) => {
  75. let arr = e.data.result.keypoints;
  76. let result = [];
  77. for (let i = 0; i < arr.length; i += 3) {
  78. result.push(arr.slice(i, i + 2));
  79. }
  80. //console.log("result", result)
  81. bodyposeData.value = result;
  82. if (boxes.value.length == 0) {
  83. // speckText("识别成功");
  84. // proxy?.$modal.msgWarning(`识别成功`);
  85. let arr = e.data.result.boxes;
  86. boxes.value = [{ x: arr[0], y: arr[3] }, { x: arr[0], y: arr[1] }, { x: arr[2], y: arr[1] }, { x: arr[2], y: arr[3] }]
  87. proportion.value = (clientObj.value.height / (arr[3] - arr[1])).toFixed(2);
  88. }
  89. getCanvas();
  90. };
  91. /**
  92. * 查询姿态识别状态
  93. */
  94. const getCheckBodypose = () => {
  95. let handcontroller_id = deviceInfo.value.handcontroller_id;
  96. //检查是否支持姿态识别
  97. checkBodypose(handcontroller_id);
  98. };
  99. /**
  100. * 关闭姿态识别
  101. */
  102. const getCloseBodypose = () => {
  103. let handcontroller_id = deviceInfo.value.handcontroller_id;
  104. terminateBodypose(handcontroller_id);
  105. bodyposeState.value = false;
  106. speckCancel(); //停止播报
  107. setTimeout(() => {
  108. if (wsState.value) {
  109. closeWS();
  110. }
  111. }, 3000)
  112. };
  113. const getCanvas = () => {
  114. //过肩或者过鼻子都算投篮
  115. let leftA = bodyposeData.value[6][1];//右肩Y
  116. let rightA = bodyposeData.value[5][1];//左肩Y
  117. let leftB = bodyposeData.value[10][1];//右手Y
  118. let rightB = bodyposeData.value[9][1];//左手Y
  119. let bizi = bodyposeData.value[0][1];//鼻子Y
  120. if (leftB > leftA || rightB > rightA || leftB > bizi || rightB > bizi) {
  121. myThrow.value = 1;
  122. } else {
  123. myThrow.value = 0;
  124. }
  125. const canvas: any = canvasRef.value;
  126. const ctx = canvas.getContext('2d');
  127. // 清空整个画布
  128. ctx.clearRect(0, 0, canvas.width, canvas.height);
  129. // 保存当前状态
  130. ctx.save();
  131. function calculateOffset(a: any, b: any) {
  132. return {
  133. x: b.x - a.x,
  134. y: b.y - a.y
  135. };
  136. }
  137. const pointA = { x: clientObj.value.width / 2, y: clientObj.value.height / 2 };
  138. const pointB = { x: (boxes.value[2].x + boxes.value[0].x) / 2, y: (boxes.value[3].y + boxes.value[1].y) / 2 };
  139. const offset = calculateOffset(pointA, pointB);
  140. ctx.translate(-offset.x, -offset.y);
  141. // console.log("Canvas分辨率", clientObj.value);
  142. // console.log("人体图片四点坐标", boxes.value)
  143. // console.log("Canvas中心", pointA);
  144. // console.log("人体中心", pointB);
  145. // console.log("offset", offset)
  146. // console.log("proportion.value",proportion.value)
  147. const originalPoints = bodyposeData.value;
  148. // 计算缩放后坐标
  149. const postData = originalPoints.map((point: any) => {
  150. const newX = (point[0] - pointB.x) * proportion.value + pointB.x;
  151. const newY = (point[1] - pointB.y) * proportion.value + pointB.y;
  152. return [newX, newY];
  153. });
  154. // console.log("原始坐标:", originalPoints);
  155. // console.log("缩放后坐标:", postData);
  156. direction.value = postData[0][0] - offset.x - (94 / 2);//鼻子X
  157. //绘制头部
  158. const point1 = { x: postData[4][0], y: postData[4][1] };
  159. const point2 = { x: postData[3][0], y: postData[3][1] };
  160. // 计算椭圆参数
  161. const centerX = (point1.x + point2.x) / 2; // 椭圆中心X
  162. const centerY = (point1.y + point2.y) / 2; // 椭圆中心Y
  163. const distance = Math.sqrt(
  164. Math.pow(point2.x - point1.x, 2) +
  165. Math.pow(point2.y - point1.y, 2)
  166. ); // 两个焦点之间的距离
  167. const radiusX = distance * 0.5; // 水平半径(可调整)
  168. const radiusY = distance * 0.6; // 垂直半径(可调整)
  169. // 1. 绘制填充椭圆
  170. ctx.beginPath();
  171. ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, Math.PI * 2);
  172. ctx.fillStyle = 'red'; // 填充颜色
  173. ctx.fill(); // 填充
  174. // 2. 绘制边框
  175. ctx.strokeStyle = 'red';
  176. ctx.lineWidth = 5;
  177. ctx.stroke();
  178. // 绘制每个点
  179. postData.forEach((point: any, index: number) => {
  180. //眼睛鼻子不显示
  181. if (![0, 1, 2].includes(index)) {
  182. const [x, y] = point;
  183. ctx.beginPath();
  184. ctx.arc(x, y, 5, 0, Math.PI * 2); // 绘制半径为5的圆点
  185. ctx.fillStyle = 'red';
  186. ctx.fill();
  187. ctx.lineWidth = 1;
  188. ctx.stroke();
  189. }
  190. });
  191. // 根据点关系连线
  192. const arr = [[10, 8], [8, 6], [6, 5], [5, 7], [7, 9], [6, 12], [5, 11], [12, 11], [12, 14], [14, 16], [11, 13], [13, 15]]
  193. arr.forEach((point: any) => {
  194. let index1 = point[0];
  195. let index2 = point[1];
  196. //连线
  197. const dian1 = { x: postData[index1][0], y: postData[index1][1] };
  198. const dian2 = { x: postData[index2][0], y: postData[index2][1] };
  199. // 绘制连线
  200. ctx.beginPath();
  201. ctx.moveTo(dian1.x, dian1.y); // 起点
  202. ctx.lineTo(dian2.x, dian2.y); // 终点
  203. ctx.strokeStyle = 'red'; // 线条颜色
  204. ctx.lineWidth = 3; // 线条宽度
  205. ctx.stroke(); // 描边
  206. });
  207. ctx.restore(); // 恢复状态
  208. };
  209. // 游戏主类的响应式状态
  210. const gameState = reactive({
  211. balls: [],
  212. hoops: [],
  213. texts: [],
  214. res: {},
  215. score: 0,
  216. started: false,
  217. gameOver: false,
  218. ballX: (640 - 93) / 2, // 篮球初始X坐标(居中)
  219. ballY: 680, // 篮球初始Y坐标(底部)
  220. ballVel: 300,
  221. ballAngleVel: 100,
  222. ballAngle: 0,
  223. ballsShot: 1,
  224. ballCharge: 0,
  225. time: 60,
  226. toNextSecond: 1,
  227. sound: false,
  228. state: 'menu',
  229. click: false,
  230. canvas: null,
  231. ctx: null,
  232. animationFrameId: null,
  233. then: 0,
  234. // 新增:键盘控制相关状态
  235. keyLeft: false,
  236. keyRight: false,
  237. spaceShoot: false, // 空格键投篮状态
  238. exitTimer: null, // 用于存储退出定时器ID
  239. bounceHeight: 20, // 拍球高度
  240. bounceSpeed: 2, // 拍球速度
  241. bouncePhase: 0, // 拍球相位(0-2π)
  242. });
  243. // 篮筐类 - 修改后
  244. class Hoop {
  245. constructor(x, y) {
  246. this.x = x;
  247. this.y = y;
  248. this.originalX = x;
  249. this.speed = 80; // 降低速度,适应更大尺寸
  250. this.direction = 1;
  251. // 滑动范围适配更大篮筐
  252. this.range = (clientObj.value.width - 300) / 2;
  253. // 碰撞点范围扩大(与视觉尺寸匹配)
  254. this.points = [
  255. { x: x + 20, y: y + 30 },
  256. { x: x + 280, y: y + 30 }
  257. ];
  258. // 新增:缩放比例(1.5倍放大)
  259. this.scale = 1.5;
  260. }
  261. update(delta) {
  262. this.x += this.speed * this.direction * delta;
  263. // 边界检测调整
  264. if (this.x > this.originalX + this.range) {
  265. this.x = this.originalX + this.range;
  266. this.direction = -1;
  267. } else if (this.x < this.originalX - this.range) {
  268. this.x = this.originalX - this.range;
  269. this.direction = 1;
  270. }
  271. // 更新碰撞点(与放大后的尺寸匹配)
  272. this.points = [
  273. { x: this.x + 20, y: this.y + 30 },
  274. { x: this.x + 280, y: this.y + 30 }
  275. ];
  276. }
  277. drawBack(ctx, game) {
  278. // 关键修改:通过scale参数放大绘制
  279. ctx.save();
  280. ctx.translate(this.x, this.y);
  281. ctx.scale(this.scale, this.scale); // 放大1.5倍
  282. drawImage(
  283. ctx,
  284. game.res['/static/images/basketball/hoop.png'],
  285. 0, 0, // 绘制起点(相对于缩放中心)
  286. 0, 0, 148, 22, // 原图裁剪范围
  287. 0, 0, 0 // 旋转参数
  288. );
  289. ctx.restore();
  290. }
  291. drawFront(ctx, game) {
  292. // 同样放大前景
  293. ctx.save();
  294. ctx.translate(this.x, this.y + 22 * this.scale); // 适配缩放后的Y位置
  295. ctx.scale(this.scale, this.scale);
  296. drawImage(
  297. ctx,
  298. game.res['/static/images/basketball/hoop.png'],
  299. 0, 0,
  300. 0, 22, 148, 178 - 22, // 原图裁剪范围
  301. 0, 0, 0
  302. );
  303. ctx.restore();
  304. }
  305. }
  306. // 篮球类
  307. class Ball {
  308. constructor(x, y) {
  309. this.x = x;
  310. this.y = y;
  311. this.vx = 0;
  312. this.vy = 0;
  313. this.speed = 100;
  314. this.canBounce = true;
  315. this.angle = 270;
  316. this.gravity = 0;
  317. this.falling = false;
  318. this.bounces = 0;
  319. this.scored = false;
  320. this.drawAngle = 0;
  321. this.angleVel = 150;
  322. this.solid = false;
  323. this.z = 1;
  324. }
  325. setAngle(angle) {
  326. this.angle = angle;
  327. this.vx = this.speed * Math.cos(this.angle * Math.PI / 180);
  328. this.vy = this.speed * Math.sin(this.angle * Math.PI / 180);
  329. this.gravity = 0;
  330. }
  331. shoot(power) {
  332. this.speed = power;
  333. this.setAngle(270);
  334. }
  335. update(delta) {
  336. this.y += this.gravity * delta;
  337. this.gravity += 10000 * delta;
  338. this.x += this.vx * delta;
  339. this.y += this.vy * delta;
  340. if (this.vx > 500) this.vx = 500;
  341. if (this.vy > 500) this.vy = 500;
  342. if (this.y < 300) {
  343. this.solid = true;
  344. }
  345. if (this.gravity > this.speed) {
  346. this.falling = true;
  347. }
  348. if (this.x + 47 > clientObj.value.width) {
  349. this.vx = this.vx * -1;
  350. this.x = clientObj.value.width - 47;
  351. }
  352. if (this.x - 47 < 0) {
  353. this.vx = this.vx * -1;
  354. this.x = 47;
  355. }
  356. this.drawAngle += this.angleVel * delta;
  357. }
  358. draw(ctx, game) {
  359. drawImage(
  360. ctx,
  361. game.res['/static/images/basketball/ball.png'],
  362. Math.floor(this.x - (93 / 2)),
  363. Math.floor(this.y - (93 / 2)),
  364. 0, 0, 93, 93, 93 / 2, 93 / 2,
  365. this.drawAngle
  366. );
  367. }
  368. }
  369. // 弹出文字类
  370. class PopText {
  371. constructor(string, x, y) {
  372. this.string = string;
  373. this.x = x;
  374. this.y = y;
  375. this.vy = -500;
  376. this.opacity = 1;
  377. }
  378. update(delta) {
  379. this.y += this.vy * delta;
  380. this.vy += 1000 * delta;
  381. if (this.vy > 0 && this.opacity > 0) {
  382. this.opacity -= 2 * delta;
  383. }
  384. if (this.opacity <= 0) {
  385. this.opacity = 0;
  386. }
  387. }
  388. draw(ctx, game) {
  389. ctx.globalAlpha = this.opacity;
  390. game.drawText(ctx, this.string, this.x + 15, this.y);
  391. ctx.globalAlpha = 1;
  392. }
  393. }
  394. // 工具函数:绘制旋转图像
  395. function drawImage(ctx, image, x, y, sx, sy, w, h, rx, ry, a) {
  396. ctx.save();
  397. ctx.translate(x + rx, y + ry);
  398. ctx.rotate(a * Math.PI / 180);
  399. ctx.drawImage(image, sx, sy, w, h, -rx, -ry, w, h);
  400. ctx.restore();
  401. }
  402. // 事件处理函数
  403. const handleMouseDown = () => {
  404. gameState.click = true;
  405. };
  406. const handleMouseUp = () => {
  407. gameState.click = false;
  408. };
  409. const handleTouchStart = () => {
  410. gameState.click = true;
  411. };
  412. const handleTouchEnd = () => {
  413. gameState.click = false;
  414. };
  415. // 键盘事件处理函数
  416. const handleKeyDown = (event: any) => {
  417. if (gameState.state !== 'play') return;
  418. if (event.key === 'ArrowLeft') {
  419. gameState.keyLeft = true;
  420. } else if (event.key === 'ArrowRight') {
  421. gameState.keyRight = true;
  422. } else if (event.key === ' ') { // 空格键触发投篮
  423. event.preventDefault(); // 防止页面滚动
  424. gameState.spaceShoot = true;
  425. }
  426. };
  427. const handleKeyUp = (event: any) => {
  428. if (event.key === 'ArrowLeft') {
  429. gameState.keyLeft = false;
  430. } else if (event.key === 'ArrowRight') {
  431. gameState.keyRight = false;
  432. } else if (event.key === ' ') { // 空格键释放
  433. gameState.spaceShoot = false;
  434. }
  435. };
  436. // 游戏方法
  437. const setupCanvas = () => {
  438. gameState.canvas = document.getElementById('canvas' + props.type);
  439. gameState.canvas.width = clientObj.value.width;
  440. gameState.canvas.height = clientObj.value.height;
  441. // gameState.canvas.width = document.documentElement.clientWidth / 2;
  442. // gameState.canvas.height = document.documentElement.clientHeight;
  443. gameState.ctx = gameState.canvas.getContext('2d');
  444. };
  445. const resizeToWindow = () => {
  446. // const w = gameState.canvas.width / gameState.canvas.height;
  447. // const h = window.innerHeight;
  448. // const ratio = h * w;
  449. // gameState.canvas.style.width = Math.floor(ratio) + 'px';
  450. // gameState.canvas.style.height = Math.floor(h) + 'px';
  451. };
  452. const drawLoadingScreen = () => {
  453. const ctx = gameState.ctx;
  454. ctx.fillStyle = 'black';
  455. ctx.fillRect(0, 0, 700, clientObj.value.width);
  456. ctx.textAlign = 'center';
  457. drawText(ctx, 'Loading...', clientObj.value.width / 2, 700 / 2, 40);
  458. ctx.textAlign = 'left';
  459. };
  460. const getResources = () => {
  461. const images = [
  462. '/static/images/basketball/background.png',
  463. '/static/images/basketball/title.png',
  464. '/static/images/basketball/ball.png',
  465. '/static/images/basketball/hoop.png'
  466. ];
  467. const sounds = [
  468. '/static/images/basketball/bounce_1.wav'
  469. ];
  470. return gameState.sound ? images.concat(sounds) : images;
  471. };
  472. const drawText = (ctx, string, x, y, size = 30) => {
  473. ctx.font = size + 'px Contrail One';
  474. ctx.lineWidth = 5;
  475. ctx.strokeStyle = 'white';
  476. ctx.strokeText(string, x, y);
  477. ctx.fillStyle = '#0098BF';
  478. ctx.fillText(string, x, y);
  479. };
  480. const playSound = (name) => {
  481. if (gameState.sound && gameState.res[name]) {
  482. gameState.res[name].currentTime = 0;
  483. gameState.res[name].play().catch(e => console.log('Sound play error:', e));
  484. }
  485. };
  486. const loadResources = () => {
  487. drawLoadingScreen();
  488. const resources = getResources();
  489. let loaded = 0;
  490. return new Promise((resolve) => {
  491. resources.forEach(resource => {
  492. const type = resource.split('.').pop();
  493. if (type === 'png') {
  494. const image = new Image();
  495. image.src = resource;
  496. image.addEventListener('load', () => {
  497. loaded++;
  498. gameState.res[resource] = image;
  499. if (loaded === resources.length) resolve();
  500. });
  501. } else if (['wav', 'mp3'].includes(type)) {
  502. const sound = new Audio();
  503. sound.src = resource;
  504. sound.addEventListener('canplaythrough', () => {
  505. loaded++;
  506. gameState.res[resource] = sound;
  507. if (loaded === resources.length) resolve();
  508. });
  509. }
  510. });
  511. });
  512. };
  513. const gameLoop = (timestamp) => {
  514. if (!gameState.then) gameState.then = timestamp;
  515. const delta = (timestamp - gameState.then) / 1000;
  516. // 更新游戏状态
  517. update(delta);
  518. // 绘制游戏
  519. draw();
  520. gameState.then = timestamp;
  521. gameState.animationFrameId = requestAnimationFrame(gameLoop);
  522. };
  523. const update = (delta) => {
  524. if (gameState.state === 'menu') {
  525. if (gameState.click) {
  526. gameState.state = 'play';
  527. gameState.click = false;
  528. }
  529. return;
  530. }
  531. if (gameState.state === 'play') {
  532. // // 更新篮球横向移动
  533. // gameState.ballX += gameState.ballVel * delta;
  534. // if (gameState.ballX > clientObj.value.width - 93) {
  535. // gameState.ballVel = -gameState.ballVel;
  536. // gameState.ballX = clientObj.value.width - 93;
  537. // }
  538. // if (gameState.ballX < 0) {
  539. // gameState.ballVel = -gameState.ballVel;
  540. // gameState.ballX = 0;
  541. // }
  542. // 添加篮筐更新逻辑
  543. gameState.hoops.forEach(hoop => {
  544. hoop.update(delta);
  545. });
  546. // 键盘控制篮球移动
  547. if (gameState.keyLeft) {
  548. //gameState.ballX -= gameState.ballVel * delta;
  549. gameState.ballX = direction.value;
  550. }
  551. if (gameState.keyRight) {
  552. //gameState.ballX += gameState.ballVel * delta;
  553. gameState.ballX = direction.value;
  554. }
  555. // 边界检查
  556. if (gameState.ballX > clientObj.value.width - 93) {
  557. gameState.ballX = clientObj.value.width - 93;
  558. }
  559. if (gameState.ballX < 0) {
  560. gameState.ballX = 0;
  561. }
  562. // 更新所有篮球
  563. for (let i = gameState.balls.length - 1; i >= 0; i--) {
  564. const ball = gameState.balls[i];
  565. if (ball.falling) {
  566. // 检测与篮筐的碰撞
  567. gameState.hoops.forEach(hoop => {
  568. // 篮筐中心计算(适配缩放后的尺寸)
  569. const scaledWidth = 148 * hoop.scale; // 原图宽度*缩放比例
  570. const cx = hoop.x + scaledWidth / 2;
  571. const cy = hoop.y + 40 * hoop.scale; // Y轴也适配缩放
  572. const dx = cx - ball.x;
  573. const dy = cy - ball.y;
  574. const mag = Math.sqrt(dx * dx + dy * dy);
  575. // 碰撞半径增大(适配放大后的篮筐)
  576. if (mag < 47 + 30 && !ball.scored) { // 从5→30
  577. ball.setAngle(90);
  578. gameState.score += 100;
  579. gameState.texts.push(new PopText('+ 100', hoop.x, hoop.y));
  580. ball.scored = true;
  581. }
  582. if (!ball.scored) {
  583. hoop.points.forEach(point => {
  584. const dx = point.x - ball.x;
  585. const dy = point.y - ball.y;
  586. const mag = Math.sqrt(dx * dx + dy * dy);
  587. // 调整碰撞检测阈值
  588. if (mag > 47 + 30 && !ball.canBounce) {
  589. ball.canBounce = true;
  590. }
  591. if (mag < 47 + 30 && ball.canBounce) {
  592. playSound('/static/images/basketball/bounce_1.wav');
  593. ball.bounces++;
  594. // 其他逻辑保持不变...
  595. }
  596. });
  597. }
  598. });
  599. }
  600. ball.update(delta);
  601. // 移除超出屏幕的球
  602. if (ball.y > clientObj.value.height) {
  603. gameState.ballX = ball.x;
  604. gameState.balls.splice(i, 1);
  605. }
  606. }
  607. // 更新计时器
  608. if (gameState.time > 0) {
  609. gameState.toNextSecond -= delta;
  610. if (gameState.toNextSecond <= 0) {
  611. gameState.time--;
  612. gameState.toNextSecond = 1;
  613. }
  614. } else {
  615. gameState.state = 'over';
  616. }
  617. // 处理投篮逻辑(修改后)
  618. const isShooting = gameState.click || gameState.spaceShoot; // 鼠标或键盘触发投篮
  619. if (isShooting && gameState.ballY <= clientObj.value.height) {
  620. if (gameState.balls.length < 1) {
  621. const ball = new Ball(gameState.ballX + (93 / 2), gameState.ballY);
  622. ball.drawAngle = gameState.ballAngle;
  623. // 使用计算好的力度投篮
  624. const shootPower = calculateShootPower();
  625. ball.shoot(shootPower);
  626. gameState.balls.push(ball);
  627. gameState.ballY = clientObj.value.height - 90;
  628. }
  629. }
  630. // 重置篮球位置
  631. if (gameState.balls.length < 1 && gameState.ballY > clientObj.value.height) {
  632. gameState.ballY -= 100 * delta;
  633. }
  634. if (!gameState.click) {
  635. gameState.ballsShot = 0;
  636. }
  637. // 更新弹出文字
  638. gameState.texts.forEach((text, i) => {
  639. text.update(delta);
  640. if (text.opacity <= 0) {
  641. gameState.texts.splice(i, 1);
  642. }
  643. });
  644. // 更新篮球角度
  645. gameState.ballAngle += 100 * delta;
  646. }
  647. // 游戏结束状态处理
  648. if (gameState.state === 'over' && gameState.click) {
  649. gameState.gameOver = false;
  650. gameState.started = false;
  651. gameState.score = 0;
  652. gameState.time = 60;
  653. gameState.balls = [];
  654. gameState.state = 'menu';
  655. gameState.click = false;
  656. }
  657. };
  658. /**
  659. * 计算确保超过篮筐的投篮力度
  660. * @returns 计算后的投篮力度
  661. */
  662. const calculateShootPower = () => {
  663. // 获取篮筐最高位置(取所有篮筐中最高的y坐标)
  664. const highestHoopY = Math.min(...gameState.hoops.map(hoop => hoop.y));
  665. // 计算需要超过篮筐的高度(额外增加200px安全距离)
  666. const requiredHeightAboveHoop = 200;
  667. // 计算从当前位置到目标高度的距离
  668. const distanceToTarget = gameState.ballY - (highestHoopY - requiredHeightAboveHoop);
  669. // 基础力度系数(可根据实际效果调整)
  670. const powerCoefficient = 4.3;
  671. // 最小力度保障(防止过小的屏幕尺寸导致力度不足)
  672. const minPower = clientObj.value.height;
  673. // 计算最终力度
  674. const calculatedPower = distanceToTarget * powerCoefficient;
  675. // 返回确保超过篮筐的力度(取计算值和最小值中的较大者)
  676. return Math.max(calculatedPower, minPower);
  677. };
  678. const draw = () => {
  679. const ctx = gameState.ctx;
  680. if (!ctx) return;
  681. // 绘制背景
  682. if (gameState.res['/static/images/basketball/background.png']) {
  683. const img = gameState.res['/static/images/basketball/background.png'];
  684. const canvasWidth = gameState.canvas.width;
  685. const canvasHeight = gameState.canvas.height;
  686. // 计算图片与画布的比例
  687. const imgRatio = img.width / img.height;
  688. const canvasRatio = canvasWidth / canvasHeight;
  689. let drawWidth, drawHeight, drawX = 0, drawY = 0;
  690. // 根据比例计算绘制尺寸
  691. if (canvasRatio > imgRatio) {
  692. // 画布更宽,按宽度缩放,高度可能超出
  693. drawWidth = canvasWidth;
  694. drawHeight = canvasWidth / imgRatio;
  695. drawY = (canvasHeight - drawHeight) / 2; // 垂直居中
  696. } else {
  697. // 画布更高,按高度缩放,宽度可能超出
  698. drawHeight = canvasHeight;
  699. drawWidth = canvasHeight * imgRatio;
  700. drawX = (canvasWidth - drawWidth) / 2; // 水平居中
  701. }
  702. // 绘制图片(可能裁剪边缘)
  703. ctx.drawImage(img, drawX, drawY, drawWidth, drawHeight);
  704. }
  705. // 绘制菜单状态
  706. if (gameState.state === 'menu') {
  707. if (gameState.res['/static/images/basketball/title.png']) {
  708. ctx.drawImage(
  709. gameState.res['/static/images/basketball/title.png'],
  710. clientObj.value.width / 2 - (492 / 2),
  711. 100
  712. );
  713. }
  714. ctx.textAlign = 'center';
  715. drawText(ctx, '请做投篮动作开始游戏', clientObj.value.width / 2, 520, 40);
  716. }
  717. // 绘制游戏状态
  718. if (gameState.state === 'play') {
  719. // 绘制篮筐背景
  720. gameState.hoops.forEach(hoop => hoop.drawBack(ctx, gameState));
  721. // 绘制正在下落的球
  722. gameState.balls.forEach(ball => {
  723. if (ball.falling) ball.draw(ctx, gameState);
  724. });
  725. // 绘制篮筐前景
  726. gameState.hoops.forEach(hoop => hoop.drawFront(ctx, gameState));
  727. // 绘制未下落的球
  728. gameState.balls.forEach(ball => {
  729. if (!ball.falling) ball.draw(ctx, gameState);
  730. });
  731. // 绘制当前可投掷的球
  732. if (gameState.balls.length < 1 && gameState.res['/static/images/basketball/ball.png']) {
  733. drawImage(
  734. ctx,
  735. gameState.res['/static/images/basketball/ball.png'],
  736. gameState.ballX,
  737. gameState.ballY,
  738. 0, 0, 93, 93,
  739. 45, 45,
  740. gameState.ballAngle
  741. );
  742. }
  743. // 绘制分数和时间
  744. // 更靠左的得分
  745. drawText(ctx, '得分: ' + gameState.score, 150, 70, 50);
  746. // 更靠右的时间
  747. drawText(ctx, '时间: ' + gameState.time, clientObj.value.width - 115, 70, 50);
  748. // 绘制弹出文字
  749. gameState.texts.forEach(text => text.draw(ctx, { drawText }));
  750. }
  751. // 绘制游戏结束界面
  752. if (gameState.state === 'over') {
  753. ctx.textAlign = 'center';
  754. drawText(ctx, 'Game Over', clientObj.value.width / 2, 200, 80);
  755. drawText(ctx, '成绩:' + gameState.score, clientObj.value.width / 2, 400, 50);
  756. ctx.textAlign = 'center';
  757. // 游戏结束后2秒自动退出
  758. if (!gameState.exitTimer) {
  759. gameState.exitTimer = setTimeout(() => {
  760. // 退出游戏,这里可以根据实际需求调整,比如返回主菜单或跳转到其他页面
  761. gameState.gameOver = false;
  762. gameState.started = false;
  763. gameState.score = 0;
  764. gameState.time = 60;
  765. gameState.balls = [];
  766. gameState.state = 'menu';
  767. gameState.click = false;
  768. gameState.exitTimer = null;
  769. // 如果需要跳转到其他页面,可以使用路由
  770. // router.push('/');
  771. }, 2000);
  772. }
  773. // 保留点击退出的功能
  774. if (gameState.click) {
  775. clearTimeout(gameState.exitTimer);
  776. gameState.gameOver = false;
  777. gameState.started = false;
  778. gameState.score = 0;
  779. gameState.time = 60;
  780. gameState.balls = [];
  781. gameState.state = 'menu';
  782. gameState.click = false;
  783. gameState.exitTimer = null;
  784. }
  785. }
  786. };
  787. // 初始化游戏
  788. const initGame = async () => {
  789. setupCanvas();
  790. resizeToWindow();
  791. window.addEventListener('resize', resizeToWindow);
  792. // 添加键盘事件监听
  793. window.addEventListener('keydown', handleKeyDown);
  794. window.addEventListener('keyup', handleKeyUp);
  795. await loadResources();
  796. // 添加篮筐
  797. gameState.hoops = [
  798. //new Hoop(110, 300),
  799. // 计算居中位置(考虑放大后的宽度148*1.5≈222)
  800. new Hoop((clientObj.value.width - 222) / 2, 150),
  801. //new Hoop(clientObj.value.width - 148 - 110, 300),
  802. ];
  803. // 开始游戏循环
  804. gameState.animationFrameId = requestAnimationFrame(gameLoop);
  805. gameState.ballY = clientObj.value.height - 90;
  806. };
  807. onBeforeMount(() => {
  808. });
  809. // 生命周期钩子
  810. onMounted(() => {
  811. clientObj.value = {
  812. width: document.querySelector('.game-container').offsetWidth,
  813. height: document.querySelector('.game-container').offsetHeight,
  814. }
  815. initGame();
  816. });
  817. onBeforeUnmount(() => {
  818. if (gameState.animationFrameId) {
  819. cancelAnimationFrame(gameState.animationFrameId);
  820. }
  821. // 清除退出定时器
  822. if (gameState.exitTimer) {
  823. clearTimeout(gameState.exitTimer);
  824. }
  825. window.removeEventListener('resize', resizeToWindow);
  826. window.removeEventListener('keydown', handleKeyDown);
  827. window.removeEventListener('keyup', handleKeyUp);
  828. });
  829. //暴露给父组件用
  830. defineExpose({
  831. getInit
  832. })
  833. </script>
  834. <style scoped>
  835. .game-container {
  836. width: 100%;
  837. height: 100vh;
  838. display: flex;
  839. justify-content: center;
  840. align-items: center;
  841. background: #000000;
  842. overflow: hidden;
  843. position: relative;
  844. }
  845. #canvas {
  846. display: block;
  847. touch-action: none;
  848. user-select: none;
  849. }
  850. </style>