basketball.vue 27 KB

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