commit 94fb922f1f0b5d5f9f593654b441e176bad805d3 Author: Rocks011 Date: Wed Dec 10 17:47:15 2025 +0800 Initial commit diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..115cc02 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,31 @@ +/* + * Eslint config file + * Documentation: https://eslint.org/docs/user-guide/configuring/ + * Install the Eslint extension before using this feature. + */ +module.exports = { + env: { + es6: true, + browser: true, + node: true, + }, + ecmaFeatures: { + modules: true, + }, + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + }, + globals: { + wx: true, + App: true, + Page: true, + getCurrentPages: true, + getApp: true, + Component: true, + requirePlugin: true, + requireMiniProgram: true, + }, + // extends: 'eslint:recommended', + rules: {}, +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61be2ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# System Files +.DS_Store +Thumbs.db + +# Node Modules +node_modules/ +miniprogram_npm/ + +# WeChat Mini Program/Game +project.private.config.json +project.local.config.json +/unpackage/ +/dist/ + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Editor +.idea/ +.vscode/ +*.swp +*.swo diff --git a/README.md b/README.md new file mode 100644 index 0000000..1a2e8e4 --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +# 示例游戏 + +示例相关说明查阅[新手教程](https://developers.weixin.qq.com/minigame/dev/guide/develop/start.html) + +## 源码目录介绍 + +``` +├── audio // 音频资源 +├── images // 图片资源 +├── js +│ ├── base +│ │ ├── animatoin.js // 帧动画的简易实现 +│ │ ├── pool.js // 对象池的简易实现 +│ │ └── sprite.js // 游戏基本元素精灵类 +│ ├── libs +│ │ └── tinyemitter.js // 事件监听和触发 +│ ├── npc +│ │ └── enemy.js // 敌机类 +│ ├── player +│ │ ├── bullet.js // 子弹类 +│ │ └── index.js // 玩家类 +│ ├── runtime +│ │ ├── background.js // 背景类 +│ │ ├── gameinfo.js // 用于展示分数和结算界面 +│ │ └── music.js // 全局音效管理器 +│ ├── databus.js // 管控游戏状态 +│ ├── main.js // 游戏入口主函数 +│ └── render.js // 基础渲染信息 +├── .eslintrc.js // 代码规范 +├── game.js // 游戏逻辑主入口 +├── game.json // 游戏运行时配置 +├── project.config.json // 项目配置 +└── project.private.config.json // 项目个人配置 +``` diff --git a/audio/bgm.mp3 b/audio/bgm.mp3 new file mode 100644 index 0000000..1464cbe Binary files /dev/null and b/audio/bgm.mp3 differ diff --git a/audio/boom.mp3 b/audio/boom.mp3 new file mode 100644 index 0000000..370ff71 Binary files /dev/null and b/audio/boom.mp3 differ diff --git a/audio/bullet.mp3 b/audio/bullet.mp3 new file mode 100644 index 0000000..e266fff Binary files /dev/null and b/audio/bullet.mp3 differ diff --git a/game.js b/game.js new file mode 100644 index 0000000..a7ae753 --- /dev/null +++ b/game.js @@ -0,0 +1,3 @@ +import Main from './js/main'; + +new Main(); diff --git a/game.json b/game.json new file mode 100644 index 0000000..f776b69 --- /dev/null +++ b/game.json @@ -0,0 +1,3 @@ +{ + "deviceOrientation": "portrait" +} diff --git a/images/Common.png b/images/Common.png new file mode 100644 index 0000000..2b4d7f1 Binary files /dev/null and b/images/Common.png differ diff --git a/images/bg.jpg b/images/bg.jpg new file mode 100644 index 0000000..7493fd4 Binary files /dev/null and b/images/bg.jpg differ diff --git a/images/bullet.png b/images/bullet.png new file mode 100644 index 0000000..c18ce71 Binary files /dev/null and b/images/bullet.png differ diff --git a/images/enemy.png b/images/enemy.png new file mode 100644 index 0000000..96ab34d Binary files /dev/null and b/images/enemy.png differ diff --git a/images/explosion1.png b/images/explosion1.png new file mode 100644 index 0000000..b67349c Binary files /dev/null and b/images/explosion1.png differ diff --git a/images/explosion10.png b/images/explosion10.png new file mode 100644 index 0000000..8eade29 Binary files /dev/null and b/images/explosion10.png differ diff --git a/images/explosion11.png b/images/explosion11.png new file mode 100644 index 0000000..92e818b Binary files /dev/null and b/images/explosion11.png differ diff --git a/images/explosion12.png b/images/explosion12.png new file mode 100644 index 0000000..e331d54 Binary files /dev/null and b/images/explosion12.png differ diff --git a/images/explosion13.png b/images/explosion13.png new file mode 100644 index 0000000..4cebe85 Binary files /dev/null and b/images/explosion13.png differ diff --git a/images/explosion14.png b/images/explosion14.png new file mode 100644 index 0000000..7aa1d22 Binary files /dev/null and b/images/explosion14.png differ diff --git a/images/explosion15.png b/images/explosion15.png new file mode 100644 index 0000000..4039733 Binary files /dev/null and b/images/explosion15.png differ diff --git a/images/explosion16.png b/images/explosion16.png new file mode 100644 index 0000000..f8217b9 Binary files /dev/null and b/images/explosion16.png differ diff --git a/images/explosion17.png b/images/explosion17.png new file mode 100644 index 0000000..bd2648d Binary files /dev/null and b/images/explosion17.png differ diff --git a/images/explosion18.png b/images/explosion18.png new file mode 100644 index 0000000..20f87a5 Binary files /dev/null and b/images/explosion18.png differ diff --git a/images/explosion19.png b/images/explosion19.png new file mode 100644 index 0000000..ad1801f Binary files /dev/null and b/images/explosion19.png differ diff --git a/images/explosion2.png b/images/explosion2.png new file mode 100644 index 0000000..4bfe9bc Binary files /dev/null and b/images/explosion2.png differ diff --git a/images/explosion3.png b/images/explosion3.png new file mode 100644 index 0000000..5f8112a Binary files /dev/null and b/images/explosion3.png differ diff --git a/images/explosion4.png b/images/explosion4.png new file mode 100644 index 0000000..e3d1556 Binary files /dev/null and b/images/explosion4.png differ diff --git a/images/explosion5.png b/images/explosion5.png new file mode 100644 index 0000000..7011421 Binary files /dev/null and b/images/explosion5.png differ diff --git a/images/explosion6.png b/images/explosion6.png new file mode 100644 index 0000000..c6a8a41 Binary files /dev/null and b/images/explosion6.png differ diff --git a/images/explosion7.png b/images/explosion7.png new file mode 100644 index 0000000..4ae23cb Binary files /dev/null and b/images/explosion7.png differ diff --git a/images/explosion8.png b/images/explosion8.png new file mode 100644 index 0000000..5ce2608 Binary files /dev/null and b/images/explosion8.png differ diff --git a/images/explosion9.png b/images/explosion9.png new file mode 100644 index 0000000..4e14b25 Binary files /dev/null and b/images/explosion9.png differ diff --git a/images/hero.png b/images/hero.png new file mode 100644 index 0000000..e5d38dc Binary files /dev/null and b/images/hero.png differ diff --git a/images/turtle.png b/images/turtle.png new file mode 100644 index 0000000..cabb824 Binary files /dev/null and b/images/turtle.png differ diff --git a/images/turtle2.png b/images/turtle2.png new file mode 100644 index 0000000..026cb8b Binary files /dev/null and b/images/turtle2.png differ diff --git a/images/turtle3.png b/images/turtle3.png new file mode 100644 index 0000000..b6eac1d Binary files /dev/null and b/images/turtle3.png differ diff --git a/images/turtle5.png b/images/turtle5.png new file mode 100644 index 0000000..296406f Binary files /dev/null and b/images/turtle5.png differ diff --git a/js/base/animation.js b/js/base/animation.js new file mode 100644 index 0000000..7864ae3 --- /dev/null +++ b/js/base/animation.js @@ -0,0 +1,89 @@ +import Sprite from './sprite'; + +const __ = { + timer: Symbol('timer'), +}; + +/** + * 简易的帧动画类实现 + */ +export default class Animation extends Sprite { + constructor(imgSrc, width, height) { + super(imgSrc, width, height); + + this.isPlaying = false; // 当前动画是否播放中 + this.loop = false; // 动画是否需要循环播放 + this.interval = 1000 / 60; // 每一帧的时间间隔 + this[__.timer] = null; // 帧定时器 + this.index = -1; // 当前播放的帧 + this.count = 0; // 总帧数 + this.imgList = []; // 帧图片集合 + } + + /** + * 初始化帧动画的所有帧 + * @param {Array} imgList - 帧图片的路径数组 + */ + initFrames(imgList) { + this.imgList = imgList.map((src) => { + const img = wx.createImage(); + img.src = src; + return img; + }); + + this.count = imgList.length; + + // 推入到全局动画池,便于全局绘图的时候遍历和绘制当前动画帧 + GameGlobal.databus.animations.push(this); + } + + // 将播放中的帧绘制到canvas上 + aniRender(ctx) { + if (this.index >= 0 && this.index < this.count) { + ctx.drawImage( + this.imgList[this.index], + this.x, + this.y, + this.width * 1.2, + this.height * 1.2 + ); + } + } + + // 播放预定的帧动画 + playAnimation(index = 0, loop = false) { + this.visible = false; // 动画播放时隐藏精灵图 + this.isPlaying = true; + this.loop = loop; + this.index = index; + + if (this.interval > 0 && this.count) { + this[__.timer] = setInterval(this.frameLoop.bind(this), this.interval); + } + } + + // 停止帧动画播放 + stopAnimation() { + this.isPlaying = false; + this.index = -1; + if (this[__.timer]) { + clearInterval(this[__.timer]); + this[__.timer] = null; // 清空定时器引用 + this.emit('stopAnimation'); + } + } + + // 帧遍历 + frameLoop() { + this.index++; + + if (this.index >= this.count) { + if (this.loop) { + this.index = 0; // 循环播放 + } else { + this.index = this.count - 1; // 保持在最后一帧 + this.stopAnimation(); // 停止播放 + } + } + } +} diff --git a/js/base/pool.js b/js/base/pool.js new file mode 100644 index 0000000..42ca138 --- /dev/null +++ b/js/base/pool.js @@ -0,0 +1,43 @@ +const __ = { + poolDic: Symbol('poolDic'), +}; + +/** + * 简易的对象池实现 + * 用于对象的存贮和重复使用 + * 可以有效减少对象创建开销和避免频繁的垃圾回收 + * 提高游戏性能 + */ +export default class Pool { + constructor() { + this[__.poolDic] = {}; + } + + /** + * 根据对象标识符 + * 获取对应的对象池 + */ + getPoolBySign(name) { + return this[__.poolDic][name] || (this[__.poolDic][name] = []); + } + + /** + * 根据传入的对象标识符,查询对象池 + * 对象池为空创建新的类,否则从对象池中取 + */ + getItemByClass(name, className) { + const pool = this.getPoolBySign(name); + + const result = pool.length ? pool.shift() : new className(); + + return result; + } + + /** + * 将对象回收到对象池 + * 方便后续继续使用 + */ + recover(name, instance) { + this.getPoolBySign(name).push(instance); + } +} diff --git a/js/base/sprite.js b/js/base/sprite.js new file mode 100644 index 0000000..d5bcadd --- /dev/null +++ b/js/base/sprite.js @@ -0,0 +1,55 @@ +import Emitter from '../libs/tinyemitter'; + +/** + * 游戏基础的精灵类 + */ +export default class Sprite extends Emitter { + visible = true; // 是否可见 + isActive = true; // 是否可碰撞 + + constructor(imgSrc = '', width = 0, height = 0, x = 0, y = 0) { + super(); + + this.img = wx.createImage(); + this.img.src = imgSrc; + + this.width = width; + this.height = height; + + this.x = x; + this.y = y; + + this.visible = true; + } + + /** + * 将精灵图绘制在canvas上 + */ + render(ctx) { + if (!this.visible) return; + + ctx.drawImage(this.img, this.x, this.y, this.width, this.height); + } + + /** + * 简单的碰撞检测定义: + * 另一个精灵的中心点处于本精灵所在的矩形内即可 + * @param{Sprite} sp: Sptite的实例 + */ + isCollideWith(sp) { + const spX = sp.x + sp.width / 2; + const spY = sp.y + sp.height / 2; + + // 不可见则不检测 + if (!this.visible || !sp.visible) return false; + // 不可碰撞则不检测 + if (!this.isActive || !sp.isActive) return false; + + return !!( + spX >= this.x && + spX <= this.x + this.width && + spY >= this.y && + spY <= this.y + this.height + ); + } +} diff --git a/js/databus.js b/js/databus.js new file mode 100644 index 0000000..d461045 --- /dev/null +++ b/js/databus.js @@ -0,0 +1,64 @@ +import Pool from './base/pool'; + +let instance; + +/** + * 全局状态管理器 + * 负责管理游戏的状态,包括帧数、分数、子弹、敌人和动画等 + */ +export default class DataBus { + // 直接在类中定义实例属性 + enemys = []; // 存储敌人 + bullets = []; // 存储子弹 + animations = []; // 存储动画 + frame = 0; // 当前帧数 + score = 0; // 当前分数 + isGameOver = false; // 游戏是否结束 + pool = new Pool(); // 初始化对象池 + + constructor() { + // 确保单例模式 + if (instance) return instance; + + instance = this; + } + + // 重置游戏状态 + reset() { + this.frame = 0; // 当前帧数 + this.score = 0; // 当前分数 + this.bullets = []; // 存储子弹 + this.enemys = []; // 存储敌人 + this.animations = []; // 存储动画 + this.isGameOver = false; // 游戏是否结束 + } + + // 游戏结束 + gameOver() { + this.isGameOver = true; + } + + /** + * 回收敌人,进入对象池 + * 此后不进入帧循环 + * @param {Object} enemy - 要回收的敌人对象 + */ + removeEnemy(enemy) { + const temp = this.enemys.splice(this.enemys.indexOf(enemy), 1); + if (temp) { + this.pool.recover('new_enemy', enemy); // 回收敌人到对象池 + } + } + + /** + * 回收子弹,进入对象池 + * 此后不进入帧循环 + * @param {Object} bullet - 要回收的子弹对象 + */ + removeBullets(bullet) { + const temp = this.bullets.splice(this.bullets.indexOf(bullet), 1); + if (temp) { + this.pool.recover('whip_bullet', bullet); // 回收子弹到对象池 + } + } +} diff --git a/js/libs/tinyemitter.js b/js/libs/tinyemitter.js new file mode 100644 index 0000000..6340f09 --- /dev/null +++ b/js/libs/tinyemitter.js @@ -0,0 +1 @@ +(function(e){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=e()}else if(typeof define==="function"&&define.amd){define([],e)}else{var n;if(typeof window!=="undefined"){n=window}else if(typeof global!=="undefined"){n=global}else if(typeof self!=="undefined"){n=self}else{n=this}n.TinyEmitter=e()}})(function(){var e,n,t;return function r(e,n,t){function i(o,u){if(!n[o]){if(!e[o]){var s=typeof require=="function"&&require;if(!u&&s)return s(o,!0);if(f)return f(o,!0);var a=new Error("Cannot find module '"+o+"'");throw a.code="MODULE_NOT_FOUND",a}var l=n[o]={exports:{}};e[o][0].call(l.exports,function(n){var t=e[o][1][n];return i(t?t:n)},l,l.exports,r,e,n,t)}return n[o].exports}var f=typeof require=="function"&&require;for(var o=0;o { + for (let i = 0, il = GameGlobal.databus.enemys.length; i < il; i++) { + const enemy = GameGlobal.databus.enemys[i]; + + // 如果敌机存活并且发生了发生碰撞 + if (enemy.isCollideWith(bullet)) { + enemy.destroy(); // 销毁敌机 + // bullet.destroy(); // 鞭子模式下子弹不销毁 + GameGlobal.databus.score += 1; // 增加分数 + break; // 退出循环 + } + } + }); + + // 检测玩家与敌机的碰撞 + for (let i = 0, il = GameGlobal.databus.enemys.length; i < il; i++) { + const enemy = GameGlobal.databus.enemys[i]; + + // 如果玩家与敌机发生碰撞 + if (this.player.isCollideWith(enemy)) { + this.player.destroy(); // 销毁玩家飞机 + GameGlobal.databus.gameOver(); // 游戏结束 + + break; // 退出循环 + } + } + } + + /** + * canvas重绘函数 + * 每一帧重新绘制所有的需要展示的元素 + */ + render() { + ctx.clearRect(0, 0, canvas.width, canvas.height); // 清空画布 + + this.bg.render(ctx); // 绘制背景 + this.player.render(ctx); // 绘制玩家飞机 + GameGlobal.databus.bullets.forEach((item) => item.render(ctx)); // 绘制所有子弹 + GameGlobal.databus.enemys.forEach((item) => item.render(ctx)); // 绘制所有敌机 + this.gameInfo.render(ctx); // 绘制游戏UI + GameGlobal.databus.animations.forEach((ani) => { + if (ani.isPlaying) { + ani.aniRender(ctx); // 渲染动画 + } + }); // 绘制所有动画 + } + + // 游戏逻辑更新主函数 + update() { + GameGlobal.databus.frame++; // 增加帧数 + + if (GameGlobal.databus.isGameOver) { + return; + } + + this.bg.update(); // 更新背景 + this.player.update(); // 更新玩家 + // 更新所有子弹 + GameGlobal.databus.bullets.forEach((item) => item.update()); + // 更新所有敌机 + GameGlobal.databus.enemys.forEach((item) => item.update()); + + this.enemyGenerate(); // 生成敌机 + this.collisionDetection(); // 检测碰撞 + } + + // 实现游戏帧循环 + loop() { + this.update(); // 更新游戏逻辑 + this.render(); // 渲染游戏画面 + + // 请求下一帧动画 + this.aniId = requestAnimationFrame(this.loop.bind(this)); + } +} diff --git a/js/npc/enemy.js b/js/npc/enemy.js new file mode 100644 index 0000000..d99521d --- /dev/null +++ b/js/npc/enemy.js @@ -0,0 +1,100 @@ +import Animation from '../base/animation'; +import { SCREEN_WIDTH, SCREEN_HEIGHT } from '../render'; + +const ENEMY_IMG_SRC = 'images/enemy.png'; +const ENEMY_WIDTH = 60; +const ENEMY_HEIGHT = 60; +const EXPLO_IMG_PREFIX = 'images/explosion'; + +export default class Enemy extends Animation { + speed = Math.random() * 2 + 1; // 飞行速度 + + constructor() { + super(ENEMY_IMG_SRC, ENEMY_WIDTH, ENEMY_HEIGHT); + } + + init(speed) { + this.x = 0; + this.y = 0; + + // 随机选择一个边生成 (0:上, 1:右, 2:下, 3:左) + const side = Math.floor(Math.random() * 4); + + switch (side) { + case 0: // Top + this.x = Math.random() * (SCREEN_WIDTH - this.width); + this.y = -this.height; + break; + case 1: // Right + this.x = SCREEN_WIDTH; + this.y = Math.random() * (SCREEN_HEIGHT - this.height); + break; + case 2: // Bottom + this.x = Math.random() * (SCREEN_WIDTH - this.width); + this.y = SCREEN_HEIGHT; + break; + case 3: // Left + this.x = -this.width; + this.y = Math.random() * (SCREEN_HEIGHT - this.height); + break; + } + + // 计算朝向中心的速度向量 + const targetX = SCREEN_WIDTH / 2 - this.width / 2; + const targetY = SCREEN_HEIGHT / 2 - this.height / 2; + + const dx = targetX - this.x; + const dy = targetY - this.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + this.speed = speed || (Math.random() * 2 + 2); // 默认速度 + + this.vx = (dx / distance) * this.speed; + this.vy = (dy / distance) * this.speed; + + this.isActive = true; + this.visible = true; + // 设置爆炸动画 + this.initExplosionAnimation(); + } + + // 预定义爆炸的帧动画 + initExplosionAnimation() { + const EXPLO_FRAME_COUNT = 19; + const frames = Array.from( + { length: EXPLO_FRAME_COUNT }, + (_, i) => `${EXPLO_IMG_PREFIX}${i + 1}.png` + ); + this.initFrames(frames); + } + + // 每一帧更新敌人位置 + update() { + if (GameGlobal.databus.isGameOver) { + return; + } + + this.x += this.vx; + this.y += this.vy; + + // 如果到达中心(或者非常接近),可以视为撞击玩家(碰撞检测会处理) + // 这里不需要额外的边界回收逻辑,因为它们最终会撞上玩家或被消灭 + } + + destroy() { + this.isActive = false; + // 播放销毁动画后移除 + this.playAnimation(); + GameGlobal.musicManager.playExplosion(); // 播放爆炸音效 + wx.vibrateShort({ + type: 'light' + }); // 轻微震动 + this.on('stopAnimation', () => this.remove.bind(this)); + } + + remove() { + this.isActive = false; + this.visible = false; + GameGlobal.databus.removeEnemy(this); + } +} diff --git a/js/player/bullet.js b/js/player/bullet.js new file mode 100644 index 0000000..82f776d --- /dev/null +++ b/js/player/bullet.js @@ -0,0 +1,48 @@ +import Sprite from '../base/sprite'; + +const BULLET_IMG_SRC = 'images/bullet.png'; +const BULLET_WIDTH = 16; +const BULLET_HEIGHT = 30; + +export default class Bullet extends Sprite { + constructor() { + super(BULLET_IMG_SRC, BULLET_WIDTH, BULLET_HEIGHT); + } + + init(x, y) { + this.x = x; + this.y = y; + this.isActive = true; + this.visible = true; + } + + setPos(x, y) { + this.x = x; + this.y = y; + } + + // 每一帧更新子弹位置 + update() { + if (GameGlobal.databus.isGameOver) { + return; + } + + // 子弹位置由外部控制(Player控制) + } + + destroy() { + this.isActive = false; + // 子弹没有销毁动画,直接移除 + this.remove(); + } + + remove() { + this.isActive = false; + this.visible = false; + // 回收子弹对象 + GameGlobal.databus.removeBullets(this); + // 注意:DataBus.removeBullets 内部调用 pool.recover('bullet', bullet) + // 我们需要修改 DataBus 或者在这里手动回收 + // 由于 DataBus 是通用的,我们最好修改 DataBus 或者在 DataBus 中传入 key + } +} diff --git a/js/player/index.js b/js/player/index.js new file mode 100644 index 0000000..133f0c8 --- /dev/null +++ b/js/player/index.js @@ -0,0 +1,161 @@ +import Animation from '../base/animation'; +import { SCREEN_WIDTH, SCREEN_HEIGHT } from '../render'; +import Bullet from './bullet'; + +// 玩家相关常量设置 +const PLAYER_IMG_SRC = 'images/turtle5.png'; +const PLAYER_WIDTH = 80; +const PLAYER_HEIGHT = 80; +const EXPLO_IMG_PREFIX = 'images/explosion'; +const PLAYER_SHOOT_INTERVAL = 20; + +export default class Player extends Animation { + constructor() { + super(PLAYER_IMG_SRC, PLAYER_WIDTH, PLAYER_HEIGHT); + + // 初始化坐标 + this.init(); + + // 初始化事件监听 + this.initEvent(); + } + + init() { + // 玩家固定在屏幕中心 + this.x = SCREEN_WIDTH / 2 - this.width / 2; + this.y = SCREEN_HEIGHT / 2 - this.height / 2; + + // 用于在手指移动的时候标识手指是否已经在飞机上了 + this.touched = false; + + this.isActive = true; + this.visible = true; + + // 设置爆炸动画 + this.initExplosionAnimation(); + + // 初始化子弹(鞭子) + this.angle = 0; + this.radius = PLAYER_WIDTH * 1.5; // 旋转半径 + this.bullets = []; + + // 创建两个子弹 + for (let i = 0; i < 2; i++) { + const bullet = GameGlobal.databus.pool.getItemByClass('whip_bullet', Bullet); + bullet.init(this.x, this.y); + this.bullets.push(bullet); + GameGlobal.databus.bullets.push(bullet); + } + } + + // 预定义爆炸的帧动画 + initExplosionAnimation() { + const EXPLO_FRAME_COUNT = 19; + const frames = Array.from( + { length: EXPLO_FRAME_COUNT }, + (_, i) => `${EXPLO_IMG_PREFIX}${i + 1}.png` + ); + this.initFrames(frames); + } + + /** + * 判断手指是否在飞机上 + * @param {Number} x: 手指的X轴坐标 + * @param {Number} y: 手指的Y轴坐标 + * @return {Boolean}: 用于标识手指是否在飞机上的布尔值 + */ + checkIsFingerOnAir(x, y) { + const deviation = 30; + return ( + x >= this.x - deviation && + y >= this.y - deviation && + x <= this.x + this.width + deviation && + y <= this.y + this.height + deviation + ); + } + + /** + * 根据手指的位置设置飞机的位置 + * 保证手指处于飞机中间 + * 同时限定飞机的活动范围限制在屏幕中 + */ + setAirPosAcrossFingerPosZ(x, y) { + const disX = Math.max( + 0, + Math.min(x - this.width / 2, SCREEN_WIDTH - this.width) + ); + const disY = Math.max( + 0, + Math.min(y - this.height / 2, SCREEN_HEIGHT - this.height) + ); + + this.x = disX; + this.y = disY; + } + + /** + * 玩家响应手指的触摸事件 + * 改变战机的位置 + */ + initEvent() { + wx.onTouchStart((e) => { + const { clientX: x, clientY: y } = e.touches[0]; + + if (GameGlobal.databus.isGameOver) { + return; + } + if (this.checkIsFingerOnAir(x, y)) { + this.touched = true; + this.setAirPosAcrossFingerPosZ(x, y); + } + }); + + wx.onTouchMove((e) => { + const { clientX: x, clientY: y } = e.touches[0]; + + if (GameGlobal.databus.isGameOver) { + return; + } + if (this.touched) { + this.setAirPosAcrossFingerPosZ(x, y); + } + }); + + wx.onTouchEnd((e) => { + this.touched = false; + }); + + wx.onTouchCancel((e) => { + this.touched = false; + }); + } + + update() { + if (GameGlobal.databus.isGameOver) { + return; + } + + // 更新旋转角度 + this.angle += 0.1; // 旋转速度 + + // 更新子弹位置 + this.bullets.forEach((bullet, index) => { + // 两个子弹相差 180 度 (PI) + const currentAngle = this.angle + index * Math.PI; + + const bulletX = this.x + this.width / 2 + Math.cos(currentAngle) * this.radius - bullet.width / 2; + const bulletY = this.y + this.height / 2 + Math.sin(currentAngle) * this.radius - bullet.height / 2; + + bullet.setPos(bulletX, bulletY); + }); + } + + destroy() { + this.isActive = false; + this.playAnimation(); + GameGlobal.musicManager.playExplosion(); // 播放爆炸音效 + wx.vibrateShort({ + type: 'medium' + }); // 震动 + } +} diff --git a/js/render.js b/js/render.js new file mode 100644 index 0000000..7b3ae0e --- /dev/null +++ b/js/render.js @@ -0,0 +1,9 @@ +GameGlobal.canvas = wx.createCanvas(); + +const windowInfo = wx.getWindowInfo ? wx.getWindowInfo() : wx.getSystemInfoSync(); + +canvas.width = windowInfo.screenWidth; +canvas.height = windowInfo.screenHeight; + +export const SCREEN_WIDTH = windowInfo.screenWidth; +export const SCREEN_HEIGHT = windowInfo.screenHeight; \ No newline at end of file diff --git a/js/runtime/background.js b/js/runtime/background.js new file mode 100644 index 0000000..4369fde --- /dev/null +++ b/js/runtime/background.js @@ -0,0 +1,52 @@ +import Sprite from '../base/sprite'; +import { SCREEN_WIDTH, SCREEN_HEIGHT } from '../render'; + +const BACKGROUND_IMAGE_SRC = 'images/bg.jpg'; +const BACKGROUND_WIDTH = 512; +const BACKGROUND_HEIGHT = 512; +const BACKGROUND_SPEED = 2; + +/** + * 游戏背景类 + * 提供 update 和 render 函数实现无限滚动的背景功能 + */ +export default class BackGround extends Sprite { + constructor() { + super(BACKGROUND_IMAGE_SRC, SCREEN_WIDTH, SCREEN_HEIGHT); + } + + update() { + // 背景不再滚动 + } + + /** + * 背景图重绘函数 + * 绘制一张静态图片 + */ + render(ctx) { + ctx.drawImage( + this.img, + 0, + 0, + this.width, + this.height, + 0, + 0, + SCREEN_WIDTH, + SCREEN_HEIGHT + ); + + // 绘制第二张背景 + ctx.drawImage( + this.img, + 0, + 0, + this.width, + this.height, + 0, + BACKGROUND_HEIGHT, + SCREEN_WIDTH, + SCREEN_HEIGHT + ); + } +} diff --git a/js/runtime/gameinfo.js b/js/runtime/gameinfo.js new file mode 100644 index 0000000..e35dbbc --- /dev/null +++ b/js/runtime/gameinfo.js @@ -0,0 +1,111 @@ +import Emitter from '../libs/tinyemitter'; +import { SCREEN_WIDTH, SCREEN_HEIGHT } from '../render'; + +const atlas = wx.createImage(); +atlas.src = 'images/Common.png'; + +export default class GameInfo extends Emitter { + constructor() { + super(); + + this.btnArea = { + startX: SCREEN_WIDTH / 2 - 40, + startY: SCREEN_HEIGHT / 2 - 100 + 180, + endX: SCREEN_WIDTH / 2 + 50, + endY: SCREEN_HEIGHT / 2 - 100 + 255, + }; + + // 绑定触摸事件 + wx.onTouchStart(this.touchEventHandler.bind(this)) + } + + setFont(ctx) { + ctx.fillStyle = '#ffffff'; + ctx.font = '20px Arial'; + } + + render(ctx) { + this.renderGameScore(ctx, GameGlobal.databus.score); // 绘制当前分数 + + // 游戏结束时停止帧循环并显示游戏结束画面 + if (GameGlobal.databus.isGameOver) { + this.renderGameOver(ctx, GameGlobal.databus.score); // 绘制游戏结束画面 + } + } + + renderGameScore(ctx, score) { + this.setFont(ctx); + ctx.fillText(score, 10, 30); + } + + renderGameOver(ctx, score) { + this.drawGameOverImage(ctx); + this.drawGameOverText(ctx, score); + this.drawRestartButton(ctx); + } + + drawGameOverImage(ctx) { + ctx.drawImage( + atlas, + 0, + 0, + 119, + 108, + SCREEN_WIDTH / 2 - 150, + SCREEN_HEIGHT / 2 - 100, + 300, + 300 + ); + } + + drawGameOverText(ctx, score) { + this.setFont(ctx); + ctx.fillText( + '游戏结束', + SCREEN_WIDTH / 2 - 40, + SCREEN_HEIGHT / 2 - 100 + 50 + ); + ctx.fillText( + `得分: ${score}`, + SCREEN_WIDTH / 2 - 40, + SCREEN_HEIGHT / 2 - 100 + 130 + ); + } + + drawRestartButton(ctx) { + ctx.drawImage( + atlas, + 120, + 6, + 39, + 24, + SCREEN_WIDTH / 2 - 60, + SCREEN_HEIGHT / 2 - 100 + 180, + 120, + 40 + ); + ctx.fillText( + '重新开始', + SCREEN_WIDTH / 2 - 40, + SCREEN_HEIGHT / 2 - 100 + 205 + ); + } + + touchEventHandler(event) { + const { clientX, clientY } = event.touches[0]; // 获取触摸点的坐标 + + // 当前只有游戏结束时展示了UI,所以只处理游戏结束时的状态 + if (GameGlobal.databus.isGameOver) { + // 检查触摸是否在按钮区域内 + if ( + clientX >= this.btnArea.startX && + clientX <= this.btnArea.endX && + clientY >= this.btnArea.startY && + clientY <= this.btnArea.endY + ) { + // 调用重启游戏的回调函数 + this.emit('restart'); + } + } + } +} diff --git a/js/runtime/music.js b/js/runtime/music.js new file mode 100644 index 0000000..749996e --- /dev/null +++ b/js/runtime/music.js @@ -0,0 +1,32 @@ +let instance; + +/** + * 统一的音效管理器 + */ +export default class Music { + bgmAudio = wx.createInnerAudioContext(); + shootAudio = wx.createInnerAudioContext(); + boomAudio = wx.createInnerAudioContext(); + + constructor() { + if (instance) return instance; + + instance = this; + + this.bgmAudio.loop = true; // 背景音乐循环播放 + this.bgmAudio.autoplay = true; // 背景音乐自动播放 + this.bgmAudio.src = 'audio/bgm.mp3'; + this.shootAudio.src = 'audio/bullet.mp3'; + this.boomAudio.src = 'audio/boom.mp3'; + } + + playShoot() { + this.shootAudio.currentTime = 0; + this.shootAudio.play(); + } + + playExplosion() { + this.boomAudio.currentTime = 0; + this.boomAudio.play(); + } +} diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..48d8221 --- /dev/null +++ b/project.config.json @@ -0,0 +1,58 @@ +{ + "description": "项目配置文件", + "setting": { + "urlCheck": false, + "es6": true, + "postcss": true, + "minified": true, + "newFeature": true, + "compileWorklet": false, + "uglifyFileName": false, + "uploadWithSourceMap": true, + "enhance": false, + "packNpmManually": false, + "packNpmRelationList": [], + "minifyWXSS": true, + "minifyWXML": true, + "localPlugins": false, + "condition": false, + "swc": false, + "disableSWC": true, + "babelSetting": { + "ignore": [], + "disablePlugins": [], + "outputPath": "" + }, + "disableUseStrict": false, + "useCompilerPlugins": false + }, + "compileType": "game", + "libVersion": "latest", + "appid": "wxbbe1578da04083a1", + "projectname": "quickstart", + "condition": { + "search": { + "current": -1, + "list": [] + }, + "conversation": { + "current": -1, + "list": [] + }, + "game": { + "currentL": -1, + "list": [] + }, + "miniprogram": { + "current": -1, + "list": [] + } + }, + "simulatorPluginLibVersion": {}, + "packOptions": { + "ignore": [], + "include": [] + }, + "isGameTourist": false, + "editorSetting": {} +} \ No newline at end of file