Initial commit

This commit is contained in:
Rocks011
2025-12-10 17:47:15 +08:00
commit 94fb922f1f
50 changed files with 1059 additions and 0 deletions

31
.eslintrc.js Normal file
View File

@@ -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: {},
}

24
.gitignore vendored Normal file
View File

@@ -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

34
README.md Normal file
View File

@@ -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 // 项目个人配置
```

BIN
audio/bgm.mp3 Normal file

Binary file not shown.

BIN
audio/boom.mp3 Normal file

Binary file not shown.

BIN
audio/bullet.mp3 Normal file

Binary file not shown.

3
game.js Normal file
View File

@@ -0,0 +1,3 @@
import Main from './js/main';
new Main();

3
game.json Normal file
View File

@@ -0,0 +1,3 @@
{
"deviceOrientation": "portrait"
}

BIN
images/Common.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
images/bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
images/bullet.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
images/enemy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
images/explosion1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

BIN
images/explosion10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
images/explosion11.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
images/explosion12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
images/explosion13.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

BIN
images/explosion14.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
images/explosion15.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
images/explosion16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

BIN
images/explosion17.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
images/explosion18.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
images/explosion19.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

BIN
images/explosion2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1017 B

BIN
images/explosion3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 995 B

BIN
images/explosion4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
images/explosion5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
images/explosion6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
images/explosion7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

BIN
images/explosion8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
images/explosion9.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

BIN
images/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
images/turtle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

BIN
images/turtle2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
images/turtle3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

BIN
images/turtle5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

89
js/base/animation.js Normal file
View File

@@ -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(); // 停止播放
}
}
}
}

43
js/base/pool.js Normal file
View File

@@ -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);
}
}

55
js/base/sprite.js Normal file
View File

@@ -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
);
}
}

64
js/databus.js Normal file
View File

@@ -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); // 回收子弹到对象池
}
}
}

1
js/libs/tinyemitter.js Normal file
View File

@@ -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<t.length;o++)i(t[o]);return i}({1:[function(e,n,t){function r(){}r.prototype={on:function(e,n,t){var r=this.e||(this.e={});(r[e]||(r[e]=[])).push({fn:n,ctx:t});return this},once:function(e,n,t){var r=this;function i(){r.off(e,i);n.apply(t,arguments)}i._=n;return this.on(e,i,t)},emit:function(e){var n=[].slice.call(arguments,1);var t=((this.e||(this.e={}))[e]||[]).slice();var r=0;var i=t.length;for(r;r<i;r++){t[r].fn.apply(t[r].ctx,n)}return this},off:function(e,n){var t=this.e||(this.e={});var r=t[e];var i=[];if(r&&n){for(var f=0,o=r.length;f<o;f++){if(r[f].fn!==n&&r[f].fn._!==n)i.push(r[f])}}i.length?t[e]=i:delete t[e];return this}};n.exports=r;n.exports.TinyEmitter=r},{}]},{},[1])(1)});

141
js/main.js Normal file
View File

@@ -0,0 +1,141 @@
import './render'; // 初始化Canvas
import Player from './player/index'; // 导入玩家类
import Enemy from './npc/enemy'; // 导入敌机类
import BackGround from './runtime/background'; // 导入背景类
import GameInfo from './runtime/gameinfo'; // 导入游戏UI类
import Music from './runtime/music'; // 导入音乐类
import DataBus from './databus'; // 导入数据类,用于管理游戏状态和数据
const ENEMY_GENERATE_INTERVAL = 30;
const ctx = canvas.getContext('2d'); // 获取canvas的2D绘图上下文;
GameGlobal.databus = new DataBus(); // 全局数据管理,用于管理游戏状态和数据
GameGlobal.musicManager = new Music(); // 全局音乐管理实例
/**
* 游戏主函数
*/
export default class Main {
aniId = 0; // 用于存储动画帧的ID
bg = new BackGround(); // 创建背景
player = new Player(); // 创建玩家
gameInfo = new GameInfo(); // 创建游戏UI显示
constructor() {
// 当开始游戏被点击时,重新开始游戏
this.gameInfo.on('restart', this.start.bind(this));
// 开始游戏
this.start();
}
/**
* 开始或重启游戏
*/
start() {
GameGlobal.databus.reset(); // 重置数据
this.player.init(); // 重置玩家状态
cancelAnimationFrame(this.aniId); // 清除上一局的动画
this.aniId = requestAnimationFrame(this.loop.bind(this)); // 开始新的动画循环
}
/**
* 随着帧数变化的敌机生成逻辑
* 帧数取模定义成生成的频率
*/
enemyGenerate() {
// 随着分数增加,生成频率增加 (最小间隔 10 帧)
const score = GameGlobal.databus.score;
const interval = Math.max(10, 30 - Math.floor(score / 5));
if (GameGlobal.databus.frame % interval === 0) {
const enemy = GameGlobal.databus.pool.getItemByClass('new_enemy', Enemy); // 从对象池获取敌机实例
// 随着分数增加,敌人速度增加
const speed = Math.random() * 2 + 1;
enemy.init(speed); // 初始化敌机
GameGlobal.databus.enemys.push(enemy); // 将敌机添加到敌机数组中
}
}
/**
* 全局碰撞检测
*/
collisionDetection() {
// 检测子弹与敌机的碰撞
GameGlobal.databus.bullets.forEach((bullet) => {
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));
}
}

100
js/npc/enemy.js Normal file
View File

@@ -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);
}
}

48
js/player/bullet.js Normal file
View File

@@ -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
}
}

161
js/player/index.js Normal file
View File

@@ -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'
}); // 震动
}
}

9
js/render.js Normal file
View File

@@ -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;

52
js/runtime/background.js Normal file
View File

@@ -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
);
}
}

111
js/runtime/gameinfo.js Normal file
View File

@@ -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');
}
}
}
}

32
js/runtime/music.js Normal file
View File

@@ -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();
}
}

58
project.config.json Normal file
View File

@@ -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": {}
}