|
@@ -1,23 +1,559 @@
|
|
|
<template>
|
|
|
- <div>
|
|
|
-
|
|
|
+ <div class="game-container">
|
|
|
+ <canvas id="canvas" @mousedown="handleMouseDown" @mouseup="handleMouseUp" @touchstart="handleTouchStart"
|
|
|
+ @touchend="handleTouchEnd"></canvas>
|
|
|
</div>
|
|
|
</template>
|
|
|
|
|
|
-<script setup name="Basketball" lang="ts">
|
|
|
+<script setup>
|
|
|
+import { onMounted, ref, reactive, onBeforeUnmount } from 'vue';
|
|
|
|
|
|
+// 游戏主类的响应式状态
|
|
|
+const gameState = reactive({
|
|
|
+ version: '0.1',
|
|
|
+ balls: [],
|
|
|
+ hoops: [],
|
|
|
+ texts: [],
|
|
|
+ res: {},
|
|
|
+ score: 0,
|
|
|
+ started: false,
|
|
|
+ gameOver: false,
|
|
|
+ ballX: 320 / 2,
|
|
|
+ ballY: 880,
|
|
|
+ ballVel: 500,
|
|
|
+ ballAngleVel: 100,
|
|
|
+ ballAngle: 0,
|
|
|
+ ballsShot: 1,
|
|
|
+ ballCharge: 0,
|
|
|
+ time: 60,
|
|
|
+ toNextSecond: 1,
|
|
|
+ sound: false,
|
|
|
+ state: 'menu',
|
|
|
+ click: false,
|
|
|
+ canvas: null,
|
|
|
+ ctx: null,
|
|
|
+ animationFrameId: null,
|
|
|
+ then: 0
|
|
|
+});
|
|
|
|
|
|
-onBeforeMount(async () => {
|
|
|
+// 篮筐类
|
|
|
+class Hoop {
|
|
|
+ constructor(x, y) {
|
|
|
+ this.x = x;
|
|
|
+ this.y = y;
|
|
|
+ this.points = [
|
|
|
+ { x: x + 7, y: y + 18 },
|
|
|
+ { x: x + 141, y: y + 18 }
|
|
|
+ ];
|
|
|
+ }
|
|
|
|
|
|
-});
|
|
|
+ drawBack(ctx, game) {
|
|
|
+ drawImage(
|
|
|
+ ctx,
|
|
|
+ game.res['/static/images/basketball/hoop.png'],
|
|
|
+ this.x,
|
|
|
+ this.y,
|
|
|
+ 0, 0, 148, 22, 0, 0, 0
|
|
|
+ );
|
|
|
+ }
|
|
|
|
|
|
-onMounted(() => {
|
|
|
+ drawFront(ctx, game) {
|
|
|
+ drawImage(
|
|
|
+ ctx,
|
|
|
+ game.res['/static/images/basketball/hoop.png'],
|
|
|
+ this.x,
|
|
|
+ this.y + 22,
|
|
|
+ 0, 22, 148, 178 - 22, 0, 0, 0
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 篮球类
|
|
|
+class Ball {
|
|
|
+ constructor(x, y) {
|
|
|
+ this.x = x;
|
|
|
+ this.y = y;
|
|
|
+ this.vx = 0;
|
|
|
+ this.vy = 0;
|
|
|
+ this.speed = 100;
|
|
|
+ this.canBounce = true;
|
|
|
+ this.angle = 270;
|
|
|
+ this.gravity = 0;
|
|
|
+ this.falling = false;
|
|
|
+ this.bounces = 0;
|
|
|
+ this.scored = false;
|
|
|
+ this.drawAngle = 0;
|
|
|
+ this.angleVel = 100;
|
|
|
+ this.solid = false;
|
|
|
+ this.z = 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ setAngle(angle) {
|
|
|
+ this.angle = angle;
|
|
|
+ this.vx = this.speed * Math.cos(this.angle * Math.PI / 180);
|
|
|
+ this.vy = this.speed * Math.sin(this.angle * Math.PI / 180);
|
|
|
+ this.gravity = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ shoot(power) {
|
|
|
+ this.speed = power + Math.floor(Math.random() * 40);
|
|
|
+ this.setAngle(270);
|
|
|
+ }
|
|
|
+
|
|
|
+ update(delta) {
|
|
|
+ this.y += this.gravity * delta;
|
|
|
+ this.gravity += 1500 * delta;
|
|
|
+ this.x += this.vx * delta;
|
|
|
+ this.y += this.vy * delta;
|
|
|
+
|
|
|
+ if (this.vx > 500) this.vx = 500;
|
|
|
+ if (this.vy > 500) this.vy = 500;
|
|
|
+
|
|
|
+ if (this.y < 300) {
|
|
|
+ this.solid = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.gravity > this.speed) {
|
|
|
+ this.falling = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.x + 47 > 640) {
|
|
|
+ this.vx = this.vx * -1;
|
|
|
+ this.x = 640 - 47;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.x - 47 < 0) {
|
|
|
+ this.vx = this.vx * -1;
|
|
|
+ this.x = 47;
|
|
|
+ }
|
|
|
+
|
|
|
+ this.drawAngle += this.angleVel * delta;
|
|
|
+ }
|
|
|
+
|
|
|
+ draw(ctx, game) {
|
|
|
+ drawImage(
|
|
|
+ ctx,
|
|
|
+ game.res['/static/images/basketball/ball.png'],
|
|
|
+ Math.floor(this.x - (93 / 2)),
|
|
|
+ Math.floor(this.y - (93 / 2)),
|
|
|
+ 0, 0, 93, 93, 93 / 2, 93 / 2,
|
|
|
+ this.drawAngle
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 弹出文字类
|
|
|
+class PopText {
|
|
|
+ constructor(string, x, y) {
|
|
|
+ this.string = string;
|
|
|
+ this.x = x;
|
|
|
+ this.y = y;
|
|
|
+ this.vy = -500;
|
|
|
+ this.opacity = 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ update(delta) {
|
|
|
+ this.y += this.vy * delta;
|
|
|
+ this.vy += 1000 * delta;
|
|
|
+
|
|
|
+ if (this.vy > 0 && this.opacity > 0) {
|
|
|
+ this.opacity -= 2 * delta;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (this.opacity <= 0) {
|
|
|
+ this.opacity = 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ draw(ctx, game) {
|
|
|
+ ctx.globalAlpha = this.opacity;
|
|
|
+ game.drawText(ctx, this.string, this.x + 15, this.y);
|
|
|
+ ctx.globalAlpha = 1;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// 工具函数:绘制旋转图像
|
|
|
+function drawImage(ctx, image, x, y, sx, sy, w, h, rx, ry, a) {
|
|
|
+ ctx.save();
|
|
|
+ ctx.translate(x + rx, y + ry);
|
|
|
+ ctx.rotate(a * Math.PI / 180);
|
|
|
+ ctx.drawImage(image, sx, sy, w, h, -rx, -ry, w, h);
|
|
|
+ ctx.restore();
|
|
|
+}
|
|
|
+
|
|
|
+// 事件处理函数
|
|
|
+const handleMouseDown = () => {
|
|
|
+ gameState.click = true;
|
|
|
+};
|
|
|
+
|
|
|
+const handleMouseUp = () => {
|
|
|
+ gameState.click = false;
|
|
|
+};
|
|
|
+
|
|
|
+const handleTouchStart = () => {
|
|
|
+ gameState.click = true;
|
|
|
+};
|
|
|
+
|
|
|
+const handleTouchEnd = () => {
|
|
|
+ gameState.click = false;
|
|
|
+};
|
|
|
+
|
|
|
+// 游戏方法
|
|
|
+const setupCanvas = () => {
|
|
|
+ gameState.canvas = document.getElementById('canvas');
|
|
|
+ gameState.canvas.width = 640;
|
|
|
+ gameState.canvas.height = 960;
|
|
|
+ gameState.ctx = gameState.canvas.getContext('2d');
|
|
|
+};
|
|
|
+
|
|
|
+const resizeToWindow = () => {
|
|
|
+ const w = gameState.canvas.width / gameState.canvas.height;
|
|
|
+ const h = window.innerHeight;
|
|
|
+ const ratio = h * w;
|
|
|
+ gameState.canvas.style.width = Math.floor(ratio) + 'px';
|
|
|
+ gameState.canvas.style.height = Math.floor(h) + 'px';
|
|
|
+};
|
|
|
+
|
|
|
+const drawLoadingScreen = () => {
|
|
|
+ const ctx = gameState.ctx;
|
|
|
+ ctx.fillStyle = 'black';
|
|
|
+ ctx.fillRect(0, 0, 960, 640);
|
|
|
+ ctx.textAlign = 'center';
|
|
|
+ drawText(ctx, 'Loading...', 640 / 2, 960 / 2, 40);
|
|
|
+ ctx.textAlign = 'left';
|
|
|
+};
|
|
|
+
|
|
|
+const getResources = () => {
|
|
|
+ const images = [
|
|
|
+ '/static/images/basketball/background.png',
|
|
|
+ '/static/images/basketball/title.png',
|
|
|
+ '/static/images/basketball/ball.png',
|
|
|
+ '/static/images/basketball/hoop.png'
|
|
|
+ ];
|
|
|
+
|
|
|
+ const sounds = [
|
|
|
+ '/static/images/basketball/bounce_1.wav'
|
|
|
+ ];
|
|
|
+
|
|
|
+ return gameState.sound ? images.concat(sounds) : images;
|
|
|
+};
|
|
|
+
|
|
|
+const drawText = (ctx, string, x, y, size = 30) => {
|
|
|
+ ctx.font = size + 'px Contrail One';
|
|
|
+ ctx.lineWidth = 5;
|
|
|
+ ctx.strokeStyle = 'white';
|
|
|
+ ctx.strokeText(string, x, y);
|
|
|
+ ctx.fillStyle = '#0098BF';
|
|
|
+ ctx.fillText(string, x, y);
|
|
|
+};
|
|
|
+
|
|
|
+const playSound = (name) => {
|
|
|
+ if (gameState.sound && gameState.res[name]) {
|
|
|
+ gameState.res[name].currentTime = 0;
|
|
|
+ gameState.res[name].play().catch(e => console.log('Sound play error:', e));
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const loadResources = () => {
|
|
|
+ drawLoadingScreen();
|
|
|
+ const resources = getResources();
|
|
|
+ let loaded = 0;
|
|
|
+
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ resources.forEach(resource => {
|
|
|
+ const type = resource.split('.').pop();
|
|
|
+
|
|
|
+ if (type === 'png') {
|
|
|
+ const image = new Image();
|
|
|
+ image.src = resource;
|
|
|
+ image.addEventListener('load', () => {
|
|
|
+ loaded++;
|
|
|
+ gameState.res[resource] = image;
|
|
|
+ if (loaded === resources.length) resolve();
|
|
|
+ });
|
|
|
+ } else if (['wav', 'mp3'].includes(type)) {
|
|
|
+ const sound = new Audio();
|
|
|
+ sound.src = resource;
|
|
|
+ sound.addEventListener('canplaythrough', () => {
|
|
|
+ loaded++;
|
|
|
+ gameState.res[resource] = sound;
|
|
|
+ if (loaded === resources.length) resolve();
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+const gameLoop = (timestamp) => {
|
|
|
+ if (!gameState.then) gameState.then = timestamp;
|
|
|
+ const delta = (timestamp - gameState.then) / 1000;
|
|
|
+
|
|
|
+ // 更新游戏状态
|
|
|
+ update(delta);
|
|
|
+ // 绘制游戏
|
|
|
+ draw();
|
|
|
|
|
|
+ gameState.then = timestamp;
|
|
|
+ gameState.animationFrameId = requestAnimationFrame(gameLoop);
|
|
|
+};
|
|
|
+
|
|
|
+const update = (delta) => {
|
|
|
+ if (gameState.state === 'menu') {
|
|
|
+ if (gameState.click) {
|
|
|
+ gameState.state = 'play';
|
|
|
+ gameState.click = false;
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (gameState.state === 'play') {
|
|
|
+ // 更新篮球横向移动
|
|
|
+ gameState.ballX += gameState.ballVel * delta;
|
|
|
+ if (gameState.ballX > 640 - 93) {
|
|
|
+ gameState.ballVel = -gameState.ballVel;
|
|
|
+ gameState.ballX = 640 - 93;
|
|
|
+ }
|
|
|
+ if (gameState.ballX < 0) {
|
|
|
+ gameState.ballVel = -gameState.ballVel;
|
|
|
+ gameState.ballX = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新所有篮球
|
|
|
+ for (let i = gameState.balls.length - 1; i >= 0; i--) {
|
|
|
+ const ball = gameState.balls[i];
|
|
|
+
|
|
|
+ if (ball.falling) {
|
|
|
+ // 检测与篮筐的碰撞
|
|
|
+ gameState.hoops.forEach(hoop => {
|
|
|
+ const cx = hoop.x + (148 / 2);
|
|
|
+ const cy = hoop.y + 40;
|
|
|
+ const dx = cx - ball.x;
|
|
|
+ const dy = cy - ball.y;
|
|
|
+ const mag = Math.sqrt(dx * dx + dy * dy);
|
|
|
+
|
|
|
+ if (mag < 47 + 5 && !ball.scored) {
|
|
|
+ ball.setAngle(90);
|
|
|
+ gameState.score += 100;
|
|
|
+ gameState.texts.push(new PopText('+ 100', hoop.x, hoop.y));
|
|
|
+ ball.scored = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!ball.scored) {
|
|
|
+ hoop.points.forEach(point => {
|
|
|
+ const dx = point.x - ball.x;
|
|
|
+ const dy = point.y - ball.y;
|
|
|
+ const mag = Math.sqrt(dx * dx + dy * dy);
|
|
|
+ const angle = Math.atan2(point.y - ball.y, point.x - ball.x);
|
|
|
+
|
|
|
+ if (mag > 47 + 7 && !ball.canBounce) {
|
|
|
+ ball.canBounce = true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (mag < 47 + 5 && ball.canBounce) {
|
|
|
+ playSound('/static/images/basketball/bounce_1.wav');
|
|
|
+ ball.bounces++;
|
|
|
+ ball.setAngle((angle * 180 / Math.PI) + 180 + Math.floor(Math.random() * 5) - Math.floor(Math.random() * 5));
|
|
|
+ ball.bounces = Math.min(ball.bounces, 3);
|
|
|
+
|
|
|
+ const deg = angle * 180 / Math.PI;
|
|
|
+ if (deg > 0 && deg < 180) {
|
|
|
+ ball.gravity = 750 + (ball.bounces * 50);
|
|
|
+ }
|
|
|
+
|
|
|
+ ball.angleVel = -ball.angleVel;
|
|
|
+ ball.canBounce = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ ball.update(delta);
|
|
|
+
|
|
|
+ // 移除超出屏幕的球
|
|
|
+ if (ball.y > 960) {
|
|
|
+ gameState.ballX = ball.x;
|
|
|
+ gameState.balls.splice(i, 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新计时器
|
|
|
+ if (gameState.time > 0) {
|
|
|
+ gameState.toNextSecond -= delta;
|
|
|
+ if (gameState.toNextSecond <= 0) {
|
|
|
+ gameState.time--;
|
|
|
+ gameState.toNextSecond = 1;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ gameState.state = 'over';
|
|
|
+ }
|
|
|
+
|
|
|
+ // 处理投篮逻辑
|
|
|
+ if (gameState.click && gameState.ballY <= 950) {
|
|
|
+ if (gameState.balls.length < 1) {
|
|
|
+ const ball = new Ball(gameState.ballX + (93 / 2), gameState.ballY);
|
|
|
+ ball.drawAngle = gameState.ballAngle;
|
|
|
+ ball.shoot(1480);
|
|
|
+ gameState.balls.push(ball);
|
|
|
+ gameState.ballY = 961;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 重置篮球位置
|
|
|
+ if (gameState.balls.length < 1 && gameState.ballY > 880) {
|
|
|
+ gameState.ballY -= 100 * delta;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!gameState.click) {
|
|
|
+ gameState.ballsShot = 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 更新弹出文字
|
|
|
+ gameState.texts.forEach((text, i) => {
|
|
|
+ text.update(delta);
|
|
|
+ if (text.opacity <= 0) {
|
|
|
+ gameState.texts.splice(i, 1);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // 更新篮球角度
|
|
|
+ gameState.ballAngle += 100 * delta;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 游戏结束状态处理
|
|
|
+ if (gameState.state === 'over' && gameState.click) {
|
|
|
+ gameState.gameOver = false;
|
|
|
+ gameState.started = false;
|
|
|
+ gameState.score = 0;
|
|
|
+ gameState.time = 60;
|
|
|
+ gameState.balls = [];
|
|
|
+ gameState.state = 'menu';
|
|
|
+ gameState.click = false;
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+const draw = () => {
|
|
|
+ const ctx = gameState.ctx;
|
|
|
+ if (!ctx) return;
|
|
|
+
|
|
|
+ // 绘制背景
|
|
|
+ if (gameState.res['/static/images/basketball/background.png']) {
|
|
|
+ ctx.drawImage(gameState.res['/static/images/basketball/background.png'], 0, 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制菜单状态
|
|
|
+ if (gameState.state === 'menu') {
|
|
|
+ if (gameState.res['/static/images/basketball/title.png']) {
|
|
|
+ ctx.drawImage(
|
|
|
+ gameState.res['/static/images/basketball/title.png'],
|
|
|
+ 640 / 2 - (492 / 2),
|
|
|
+ 100
|
|
|
+ );
|
|
|
+ }
|
|
|
+ ctx.textAlign = 'center';
|
|
|
+ drawText(ctx, 'Click / Touch to Start!', 640 / 2, 520, 40);
|
|
|
+ drawText(ctx, '(?) Tap to throw ball. Try to make as many hoops before the time runs out!', 640 / 2, 940, 20);
|
|
|
+ ctx.textAlign = 'left';
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制游戏状态
|
|
|
+ if (gameState.state === 'play') {
|
|
|
+ // 绘制篮筐背景
|
|
|
+ gameState.hoops.forEach(hoop => hoop.drawBack(ctx, gameState));
|
|
|
+
|
|
|
+ // 绘制正在下落的球
|
|
|
+ gameState.balls.forEach(ball => {
|
|
|
+ if (ball.falling) ball.draw(ctx, gameState);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 绘制篮筐前景
|
|
|
+ gameState.hoops.forEach(hoop => hoop.drawFront(ctx, gameState));
|
|
|
+
|
|
|
+ // 绘制未下落的球
|
|
|
+ gameState.balls.forEach(ball => {
|
|
|
+ if (!ball.falling) ball.draw(ctx, gameState);
|
|
|
+ });
|
|
|
+
|
|
|
+ // 绘制当前可投掷的球
|
|
|
+ if (gameState.balls.length < 1 && gameState.res['/static/images/basketball/ball.png']) {
|
|
|
+ drawImage(
|
|
|
+ ctx,
|
|
|
+ gameState.res['/static/images/basketball/ball.png'],
|
|
|
+ gameState.ballX,
|
|
|
+ gameState.ballY,
|
|
|
+ 0, 0, 93, 93,
|
|
|
+ 45, 45,
|
|
|
+ gameState.ballAngle
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制分数和时间
|
|
|
+ drawText(ctx, 'Score: ' + gameState.score, 45, 70, 50);
|
|
|
+ drawText(ctx, 'Time: ' + gameState.time, 435, 70, 50);
|
|
|
+
|
|
|
+ // 绘制弹出文字
|
|
|
+ gameState.texts.forEach(text => text.draw(ctx, { drawText }));
|
|
|
+ }
|
|
|
+
|
|
|
+ // 绘制游戏结束界面
|
|
|
+ if (gameState.state === 'over') {
|
|
|
+ ctx.textAlign = 'center';
|
|
|
+ drawText(ctx, 'Game Over', 640 / 2, 200, 80);
|
|
|
+ drawText(ctx, 'Score: ' + gameState.score, 640 / 2, 400, 50);
|
|
|
+ drawText(ctx, 'Click to Continue', 640 / 2, 800, 50);
|
|
|
+ ctx.textAlign = 'center';
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 初始化游戏
|
|
|
+const initGame = async () => {
|
|
|
+ setupCanvas();
|
|
|
+ resizeToWindow();
|
|
|
+ window.addEventListener('resize', resizeToWindow);
|
|
|
+
|
|
|
+ await loadResources();
|
|
|
+
|
|
|
+ // 添加篮筐
|
|
|
+ gameState.hoops = [
|
|
|
+ new Hoop(110, 520),
|
|
|
+ new Hoop(640 - 148 - 110, 520),
|
|
|
+ new Hoop(640 / 2 - (148 / 2), 260)
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 开始游戏循环
|
|
|
+ gameState.animationFrameId = requestAnimationFrame(gameLoop);
|
|
|
+};
|
|
|
+
|
|
|
+// 生命周期钩子
|
|
|
+onMounted(() => {
|
|
|
+ initGame();
|
|
|
+ setInterval(()=>{
|
|
|
+ handleMouseDown()
|
|
|
+ },500)
|
|
|
});
|
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
-
|
|
|
+ if (gameState.animationFrameId) {
|
|
|
+ cancelAnimationFrame(gameState.animationFrameId);
|
|
|
+ }
|
|
|
+ window.removeEventListener('resize', resizeToWindow);
|
|
|
});
|
|
|
</script>
|
|
|
|
|
|
-<style lang="scss" scoped></style>
|
|
|
+<style scoped>
|
|
|
+.game-container {
|
|
|
+ width: 100vw;
|
|
|
+ height: 100vh;
|
|
|
+ display: flex;
|
|
|
+ justify-content: center;
|
|
|
+ align-items: center;
|
|
|
+ background-color: #000;
|
|
|
+ overflow: hidden;
|
|
|
+}
|
|
|
+
|
|
|
+#canvas {
|
|
|
+ display: block;
|
|
|
+ touch-action: none;
|
|
|
+ user-select: none;
|
|
|
+}
|
|
|
+</style>
|