Initial commit: 汪汪大作战微信小游戏
31
.eslintrc.js
Normal 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: {},
|
||||
}
|
||||
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
27d0f61457bd5f9dcb0ed957d988e9d0.apk
|
||||
apk_extracted/
|
||||
tools/
|
||||
header.jpg
|
||||
.claude/
|
||||
36
README.md
Normal file
@ -0,0 +1,36 @@
|
||||
# 汪汪大作战(微信小游戏版)
|
||||
|
||||
对着麦克风喊"汪"来战斗的实时对战小游戏。
|
||||
|
||||
## 玩法
|
||||
|
||||
1. 在主菜单选择你的狗狗
|
||||
2. 点击"开始游戏"
|
||||
3. 倒计时结束后对着麦克风大声、清晰地喊"汪!"
|
||||
4. 用声音把能量条推到对方一侧即可 K.O. 获胜
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
├── game.js # 小游戏入口
|
||||
├── game.json # 小游戏配置
|
||||
├── project.config.json # 微信开发者工具配置
|
||||
├── images/wangwang/ # 游戏素材
|
||||
│ ├── dogs/ # 狗狗立绘
|
||||
│ ├── ui/ # UI 元素
|
||||
│ └── enemies/ # 敌人素材(备用)
|
||||
└── js/
|
||||
├── main.js # 游戏主逻辑
|
||||
├── barkDetector.js # 狗叫检测算法
|
||||
├── audioRecorder.js # 麦克风录音 + PCM 分析
|
||||
├── ai.js # AI 对手
|
||||
├── storage.js # 本地存档
|
||||
└── render.js # Canvas 初始化
|
||||
```
|
||||
|
||||
## 技术要点
|
||||
|
||||
- 使用 `RecorderManager.onFrameRecorded` 获取实时 PCM 音频帧
|
||||
- 基于 RMS 音量 + 过零率 + 中高频能量的状态机实现"汪"声检测
|
||||
- AI 对手采用 IDLE → BARK → COOLDOWN 状态机
|
||||
- 使用 `requestAnimationFrame` 驱动 Canvas 2D 游戏循环
|
||||
BIN
images/wangwang/dogs/corgi_bark.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
images/wangwang/dogs/corgi_idle.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
images/wangwang/dogs/golden.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
images/wangwang/dogs/husky.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
images/wangwang/dogs/pomeranian_bark.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
images/wangwang/dogs/pomeranian_idle.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
images/wangwang/dogs/shiba.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
images/wangwang/enemies/crab1.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
images/wangwang/enemies/crab2.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
images/wangwang/ui/back.jpg
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
images/wangwang/ui/bg.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
images/wangwang/ui/coin.jpg
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
images/wangwang/ui/defeat.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
images/wangwang/ui/frame.jpg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
images/wangwang/ui/hp_enemy.jpg
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
images/wangwang/ui/hp_heart.jpg
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
images/wangwang/ui/hp_player.jpg
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
images/wangwang/ui/lock.jpg
Normal file
|
After Width: | Height: | Size: 950 B |
BIN
images/wangwang/ui/start.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
images/wangwang/ui/victory.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
54
js/ai.js
Normal file
@ -0,0 +1,54 @@
|
||||
export default class AIOpponent {
|
||||
constructor(difficulty = 'normal') {
|
||||
this.difficulty = difficulty;
|
||||
this.state = 'IDLE';
|
||||
this.stateTimer = 0;
|
||||
this.barkStrength = 0.7;
|
||||
|
||||
const configs = {
|
||||
easy: { idleMin: 0.8, idleMax: 2.5, barkMin: 0.3, barkMax: 0.6, cooldownMin: 0.8, cooldownMax: 1.5, strengthMin: 0.4, strengthMax: 0.7 },
|
||||
normal: { idleMin: 1.0, idleMax: 2.5, barkMin: 0.2, barkMax: 0.4, cooldownMin: 0.8, cooldownMax: 1.5, strengthMin: 0.3, strengthMax: 0.5 },
|
||||
hard: { idleMin: 0.1, idleMax: 0.6, barkMin: 0.15, barkMax: 0.3, cooldownMin: 0.1, cooldownMax: 0.4, strengthMin: 0.8, strengthMax: 1.0 }
|
||||
};
|
||||
this.config = configs[difficulty] || configs.normal;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state = 'IDLE';
|
||||
this.stateTimer = this._rand(this.config.idleMin, this.config.idleMax);
|
||||
this.barkStrength = 0;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
this.stateTimer -= dt;
|
||||
if (this.stateTimer > 0) return null;
|
||||
|
||||
switch (this.state) {
|
||||
case 'IDLE':
|
||||
this.state = 'BARK';
|
||||
this.stateTimer = this._rand(this.config.barkMin, this.config.barkMax);
|
||||
this.barkStrength = this._rand(this.config.strengthMin, this.config.strengthMax);
|
||||
return null;
|
||||
|
||||
case 'BARK':
|
||||
{
|
||||
const power = this.barkStrength * 0.06;
|
||||
this.state = 'COOLDOWN';
|
||||
this.stateTimer = this._rand(this.config.cooldownMin, this.config.cooldownMax);
|
||||
return { type: 'bark', power };
|
||||
}
|
||||
|
||||
case 'COOLDOWN':
|
||||
this.state = 'IDLE';
|
||||
this.stateTimer = this._rand(this.config.idleMin, this.config.idleMax);
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
_rand(min, max) {
|
||||
return min + Math.random() * (max - min);
|
||||
}
|
||||
}
|
||||
148
js/audioRecorder.js
Normal file
@ -0,0 +1,148 @@
|
||||
import BarkDetector from './barkDetector';
|
||||
|
||||
export default class AudioRecorder {
|
||||
constructor(options = {}) {
|
||||
this.recorder = wx.getRecorderManager();
|
||||
this.detector = new BarkDetector(options);
|
||||
this.isRecording = false;
|
||||
this.sampleRate = options.sampleRate || 16000;
|
||||
this.frameSize = options.frameSize || 8192;
|
||||
this.onUpdate = options.onUpdate || null;
|
||||
this.onError = options.onError || null;
|
||||
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
this.recorder.onStart(() => {
|
||||
this.isRecording = true;
|
||||
});
|
||||
|
||||
this.recorder.onStop(() => {
|
||||
this.isRecording = false;
|
||||
});
|
||||
|
||||
this.recorder.onError((err) => {
|
||||
this.isRecording = false;
|
||||
if (this.onError) this.onError(err);
|
||||
});
|
||||
|
||||
this.recorder.onFrameRecorded((res) => {
|
||||
if (!res.frameBuffer || res.frameBuffer.byteLength === 0) return;
|
||||
|
||||
const pcm = new Int16Array(res.frameBuffer);
|
||||
this.detector.feed(pcm, this.sampleRate);
|
||||
|
||||
const status = this.detector.getStatus();
|
||||
if (this.onUpdate) {
|
||||
this.onUpdate(status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.isRecording) return;
|
||||
|
||||
// 先检查并请求麦克风权限
|
||||
this._checkAndRequestPermission()
|
||||
.then(() => this._doStart())
|
||||
.catch((err) => {
|
||||
console.error('mic permission error', err);
|
||||
if (this.onError) this.onError(err);
|
||||
});
|
||||
}
|
||||
|
||||
_checkAndRequestPermission() {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.getSetting({
|
||||
success: (res) => {
|
||||
const auth = res.authSetting['scope.record'];
|
||||
|
||||
if (auth === true) {
|
||||
// 已授权
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (auth === false) {
|
||||
// 之前拒绝过,需要引导去设置页
|
||||
this._showOpenSettings(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
// 未请求过权限,发起授权请求
|
||||
wx.authorize({
|
||||
scope: 'scope.record',
|
||||
success: () => resolve(),
|
||||
fail: (err) => {
|
||||
// 用户点击拒绝
|
||||
if (err.errCode === 12001 || (err.errMsg && err.errMsg.includes('auth deny'))) {
|
||||
this._showOpenSettings(reject);
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
fail: reject
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
_showOpenSettings(reject) {
|
||||
wx.showModal({
|
||||
title: '需要麦克风权限',
|
||||
content: '汪汪大作战需要麦克风来检测你的狗叫声,请在设置中打开麦克风权限。',
|
||||
confirmText: '去设置',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
wx.openSetting({
|
||||
success: (settingRes) => {
|
||||
if (settingRes.authSetting['scope.record']) {
|
||||
this._doStart();
|
||||
} else {
|
||||
reject(new Error('用户未在设置中开启麦克风权限'));
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
});
|
||||
} else {
|
||||
reject(new Error('用户取消授权'));
|
||||
}
|
||||
},
|
||||
fail: reject
|
||||
});
|
||||
}
|
||||
|
||||
_doStart() {
|
||||
try {
|
||||
// 微信小游戏 onFrameRecorded 在部分环境(尤其是开发者工具模拟器)下不会实时触发,
|
||||
// 在真机上通常正常。这里用 1KB 帧大小(约 64ms)尝试获得较频繁的回调。
|
||||
this.recorder.start({
|
||||
duration: 600000,
|
||||
sampleRate: 16000,
|
||||
numberOfChannels: 1,
|
||||
format: 'PCM',
|
||||
frameSize: 1
|
||||
});
|
||||
// recorder started
|
||||
} catch (err) {
|
||||
console.error('[AudioRecorder] recorder.start() error', err);
|
||||
if (this.onError) this.onError(err);
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (!this.isRecording) return;
|
||||
this.recorder.stop();
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.detector.reset();
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return this.detector.getStatus();
|
||||
}
|
||||
}
|
||||
155
js/barkDetector.js
Normal file
@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 狗叫检测器
|
||||
* 基于 PCM 数据实时检测「汪」声
|
||||
*/
|
||||
export default class BarkDetector {
|
||||
constructor(options = {}) {
|
||||
this.volumeThreshold = options.volumeThreshold || 0.03;
|
||||
this.minBarkDuration = options.minBarkDuration || 0.08;
|
||||
this.maxBarkDuration = options.maxBarkDuration || 0.6;
|
||||
this.minZCR = options.minZCR || 0.03;
|
||||
this.maxZCR = options.maxZCR || 0.35;
|
||||
this.comboWindow = options.comboWindow || 1000;
|
||||
|
||||
this.state = 'SILENT';
|
||||
this.stateStartTime = 0;
|
||||
this.lastBarkTime = 0;
|
||||
this.combo = 0;
|
||||
this.volume = 0;
|
||||
this.isBark = false;
|
||||
this.pushPower = 0;
|
||||
|
||||
this.noiseBaseline = 0;
|
||||
this.baselineFrames = 0;
|
||||
|
||||
this.onBark = options.onBark || null;
|
||||
}
|
||||
|
||||
feed(pcm, sampleRate, time) {
|
||||
if (!pcm || pcm.length === 0) return;
|
||||
|
||||
const now = time || Date.now();
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < pcm.length; i++) {
|
||||
const v = pcm[i] / 32768;
|
||||
sum += v * v;
|
||||
}
|
||||
const rms = Math.sqrt(sum / pcm.length);
|
||||
|
||||
let zcrCount = 0;
|
||||
for (let i = 1; i < pcm.length; i++) {
|
||||
if ((pcm[i] >= 0) !== (pcm[i - 1] >= 0)) {
|
||||
zcrCount++;
|
||||
}
|
||||
}
|
||||
const zcr = zcrCount / pcm.length;
|
||||
|
||||
if (this.baselineFrames < 20) {
|
||||
this.noiseBaseline = this.noiseBaseline * 0.8 + rms * 0.2;
|
||||
this.baselineFrames++;
|
||||
this.volume = rms;
|
||||
return;
|
||||
}
|
||||
|
||||
const adaptiveThreshold = Math.max(this.volumeThreshold, this.noiseBaseline + 0.03);
|
||||
|
||||
let diffSum = 0;
|
||||
const totalSum = sum || 1;
|
||||
for (let i = 1; i < pcm.length; i++) {
|
||||
const diff = (pcm[i] - pcm[i - 1]) / 32768;
|
||||
diffSum += diff * diff;
|
||||
}
|
||||
const highFreqRatio = diffSum / totalSum;
|
||||
|
||||
this.volume = rms;
|
||||
|
||||
const isAboveThreshold = rms > adaptiveThreshold;
|
||||
const isValidBarkSound = zcr >= this.minZCR && zcr <= this.maxZCR && highFreqRatio > 0.1;
|
||||
|
||||
this.isBark = false;
|
||||
this.pushPower = 0;
|
||||
|
||||
switch (this.state) {
|
||||
case 'SILENT':
|
||||
if (isAboveThreshold && isValidBarkSound) {
|
||||
this.state = 'ATTACK';
|
||||
this.stateStartTime = now;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ATTACK':
|
||||
if (isAboveThreshold && isValidBarkSound) {
|
||||
this.state = 'SUSTAIN';
|
||||
} else if (!isAboveThreshold) {
|
||||
this.state = 'SILENT';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SUSTAIN':
|
||||
if (!isAboveThreshold) {
|
||||
this.state = 'RELEASE';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'RELEASE':
|
||||
{
|
||||
const duration = (now - this.stateStartTime) / 1000;
|
||||
if (duration >= this.minBarkDuration && duration <= this.maxBarkDuration) {
|
||||
this._triggerBark(rms, now);
|
||||
}
|
||||
this.state = 'SILENT';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.state === 'SUSTAIN' || this.state === 'ATTACK') {
|
||||
const duration = (now - this.stateStartTime) / 1000;
|
||||
if (duration > this.maxBarkDuration) {
|
||||
this.state = 'SILENT';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_triggerBark(volume, now) {
|
||||
if (now - this.lastBarkTime < this.comboWindow) {
|
||||
this.combo++;
|
||||
} else {
|
||||
this.combo = 1;
|
||||
}
|
||||
this.lastBarkTime = now;
|
||||
this.isBark = true;
|
||||
|
||||
const comboBonus = 1 + (this.combo - 1) * 0.2;
|
||||
this.pushPower = Math.min(volume * comboBonus * 3, 0.25);
|
||||
|
||||
if (this.onBark) {
|
||||
this.onBark({
|
||||
volume,
|
||||
combo: this.combo,
|
||||
pushPower: this.pushPower,
|
||||
time: now
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.state = 'SILENT';
|
||||
this.combo = 0;
|
||||
this.volume = 0;
|
||||
this.isBark = false;
|
||||
this.pushPower = 0;
|
||||
this.baselineFrames = 0;
|
||||
this.noiseBaseline = 0;
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
volume: this.volume,
|
||||
isBark: this.isBark,
|
||||
combo: this.combo,
|
||||
pushPower: this.pushPower,
|
||||
state: this.state
|
||||
};
|
||||
}
|
||||
}
|
||||
505
js/main.js
Normal file
@ -0,0 +1,505 @@
|
||||
import { SCREEN_WIDTH, SCREEN_HEIGHT } from './render';
|
||||
import AudioRecorder from './audioRecorder';
|
||||
import AIOpponent from './ai';
|
||||
import storage from './storage';
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const DOGS = [
|
||||
{ id: 'shiba', name: '柴犬', desc: '平衡型', image: 'images/wangwang/dogs/shiba.png', thumb: 'images/wangwang/dogs/shiba.png', unlockWP: 0 },
|
||||
{ id: 'pomeranian', name: '博美', desc: '连击加成', image: 'images/wangwang/dogs/pomeranian_bark.png', thumb: 'images/wangwang/dogs/pomeranian_idle.png', unlockWP: 0 },
|
||||
{ id: 'husky', name: '哈士奇', desc: '音量需求-', image: 'images/wangwang/dogs/husky.png', thumb: 'images/wangwang/dogs/husky.png', unlockWP: 200 },
|
||||
{ id: 'corgi', name: '柯基', desc: 'K.O.伤害+', image: 'images/wangwang/dogs/corgi_bark.png', thumb: 'images/wangwang/dogs/corgi_idle.png', unlockWP: 400 },
|
||||
{ id: 'golden', name: '金毛', desc: '音量加成+', image: 'images/wangwang/dogs/golden.png', thumb: 'images/wangwang/dogs/golden.png', unlockWP: 600 }
|
||||
];
|
||||
|
||||
const ASSETS = {
|
||||
bg: 'images/wangwang/ui/bg.jpg',
|
||||
start: 'images/wangwang/ui/start.jpg',
|
||||
back: 'images/wangwang/ui/back.jpg',
|
||||
victory: 'images/wangwang/ui/victory.jpg',
|
||||
defeat: 'images/wangwang/ui/defeat.jpg',
|
||||
hpPlayer: 'images/wangwang/ui/hp_player.jpg',
|
||||
hpEnemy: 'images/wangwang/ui/hp_enemy.jpg',
|
||||
coin: 'images/wangwang/ui/coin.jpg',
|
||||
lock: 'images/wangwang/ui/lock.jpg'
|
||||
};
|
||||
|
||||
export default class Main {
|
||||
state = 'MENU'; // MENU | COUNTDOWN | BATTLE | RESULT
|
||||
images = {};
|
||||
energy = 50;
|
||||
combo = 0;
|
||||
volume = 0;
|
||||
ai = null;
|
||||
recorder = null;
|
||||
aniId = 0;
|
||||
lastTime = 0;
|
||||
countdown = 3;
|
||||
countdownTimer = null;
|
||||
selectedDog = 'shiba';
|
||||
unlocked = ['shiba', 'pomeranian'];
|
||||
wp = 0;
|
||||
isWin = false;
|
||||
rewardWP = 0;
|
||||
touchAreas = [];
|
||||
|
||||
constructor() {
|
||||
this.wp = storage.getWP();
|
||||
this.unlocked = storage.getUnlocked();
|
||||
this.selectedDog = storage.getSelectedDog();
|
||||
|
||||
this.loadImages().then(() => {
|
||||
this.bindTouch();
|
||||
this.renderMenu();
|
||||
});
|
||||
}
|
||||
|
||||
loadImages() {
|
||||
const allSources = { ...ASSETS };
|
||||
DOGS.forEach(d => {
|
||||
allSources[`dog_${d.id}`] = d.image;
|
||||
allSources[`thumb_${d.id}`] = d.thumb;
|
||||
});
|
||||
|
||||
const promises = Object.entries(allSources).map(([key, src]) => {
|
||||
return new Promise((resolve) => {
|
||||
const img = wx.createImage();
|
||||
img.onload = () => {
|
||||
this.images[key] = img;
|
||||
resolve();
|
||||
};
|
||||
img.onerror = () => {
|
||||
console.error('load image failed', src);
|
||||
resolve();
|
||||
};
|
||||
img.src = src;
|
||||
});
|
||||
});
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
bindTouch() {
|
||||
wx.onTouchStart((e) => {
|
||||
const touch = e.touches[0];
|
||||
this.handleTouch(touch.clientX, touch.clientY);
|
||||
});
|
||||
}
|
||||
|
||||
handleTouch(x, y) {
|
||||
for (const area of this.touchAreas) {
|
||||
if (
|
||||
x >= area.x && x <= area.x + area.w &&
|
||||
y >= area.y && y <= area.y + area.h
|
||||
) {
|
||||
area.action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== MENU =====
|
||||
renderMenu() {
|
||||
this.state = 'MENU';
|
||||
this.touchAreas = [];
|
||||
this.drawMenu();
|
||||
}
|
||||
|
||||
drawMenu() {
|
||||
const w = SCREEN_WIDTH;
|
||||
const h = SCREEN_HEIGHT;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (this.images.bg) {
|
||||
ctx.drawImage(this.images.bg, 0, 0, w, h);
|
||||
} else {
|
||||
ctx.fillStyle = '#FFD97A';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
// 标题
|
||||
ctx.fillStyle = '#8B4513';
|
||||
ctx.font = 'bold 36px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('汪汪大作战', w / 2, 80);
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.fillText('对着麦克风喊"汪"来战斗!', w / 2, 105);
|
||||
|
||||
// WP 显示
|
||||
this.drawWP(w - 80, 40);
|
||||
|
||||
// 当前选中狗展示
|
||||
const dog = DOGS.find(d => d.id === this.selectedDog);
|
||||
if (dog && this.images[`dog_${dog.id}`]) {
|
||||
const dw = 180;
|
||||
const dh = 180;
|
||||
ctx.drawImage(this.images[`dog_${dog.id}`], (w - dw) / 2, 140, dw, dh);
|
||||
}
|
||||
|
||||
if (dog) {
|
||||
ctx.fillStyle = '#5D4037';
|
||||
ctx.font = 'bold 22px sans-serif';
|
||||
ctx.fillText(dog.name, w / 2, 340);
|
||||
ctx.font = '14px sans-serif';
|
||||
ctx.fillText(dog.desc, w / 2, 365);
|
||||
}
|
||||
|
||||
// 狗狗选择栏
|
||||
const itemW = 70;
|
||||
const itemH = 90;
|
||||
const gap = 15;
|
||||
const totalW = DOGS.length * itemW + (DOGS.length - 1) * gap;
|
||||
const startX = (w - totalW) / 2;
|
||||
const listY = 400;
|
||||
|
||||
DOGS.forEach((d, i) => {
|
||||
const x = startX + i * (itemW + gap);
|
||||
const y = listY;
|
||||
const isSelected = d.id === this.selectedDog;
|
||||
const isUnlocked = this.unlocked.includes(d.id) || d.unlockWP === 0;
|
||||
|
||||
ctx.fillStyle = isSelected ? 'rgba(255, 200, 150, 0.9)' : 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.strokeStyle = isSelected ? '#FF6B00' : 'transparent';
|
||||
ctx.lineWidth = 3;
|
||||
this.roundRect(x, y, itemW, itemH, 10);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
if (this.images[`thumb_${d.id}`]) {
|
||||
ctx.save();
|
||||
if (!isUnlocked) ctx.globalAlpha = 0.5;
|
||||
ctx.drawImage(this.images[`thumb_${d.id}`], x + 5, y + 5, itemW - 10, itemH - 35);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
if (!isUnlocked && this.images.lock) {
|
||||
ctx.drawImage(this.images.lock, x + itemW - 22, y + 4, 18, 18);
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#5D4037';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(d.name, x + itemW / 2, y + itemH - 10);
|
||||
|
||||
this.touchAreas.push({
|
||||
x, y, w: itemW, h: itemH,
|
||||
action: () => this.onSelectDog(d)
|
||||
});
|
||||
});
|
||||
|
||||
// 开始按钮
|
||||
if (this.images.start) {
|
||||
const bw = 220;
|
||||
const bh = 75;
|
||||
const bx = (w - bw) / 2;
|
||||
const by = 540;
|
||||
ctx.drawImage(this.images.start, bx, by, bw, bh);
|
||||
|
||||
this.touchAreas.push({
|
||||
x: bx, y: by, w: bw, h: bh,
|
||||
action: () => this.startCountdown()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
drawWP(x, y) {
|
||||
if (this.images.coin) {
|
||||
ctx.drawImage(this.images.coin, x - 20, y - 12, 24, 24);
|
||||
}
|
||||
ctx.fillStyle = '#D35400';
|
||||
ctx.font = 'bold 16px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('WP: ' + this.wp, x + 8, y + 6);
|
||||
}
|
||||
|
||||
onSelectDog(dog) {
|
||||
if (!this.unlocked.includes(dog.id) && dog.unlockWP > 0) {
|
||||
if (this.wp >= dog.unlockWP) {
|
||||
this.wp = storage.addWP(-dog.unlockWP);
|
||||
this.unlocked = storage.unlockDog(dog.id);
|
||||
this.selectedDog = dog.id;
|
||||
storage.setSelectedDog(dog.id);
|
||||
}
|
||||
} else {
|
||||
this.selectedDog = dog.id;
|
||||
storage.setSelectedDog(dog.id);
|
||||
}
|
||||
this.drawMenu();
|
||||
}
|
||||
|
||||
// ===== COUNTDOWN =====
|
||||
startCountdown() {
|
||||
this.state = 'COUNTDOWN';
|
||||
this.countdown = 3;
|
||||
this.touchAreas = [];
|
||||
|
||||
this.countdownTimer = setInterval(() => {
|
||||
this.countdown--;
|
||||
if (this.countdown <= 0) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.startBattle();
|
||||
} else {
|
||||
this.drawCountdown();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
this.drawCountdown();
|
||||
}
|
||||
|
||||
drawCountdown() {
|
||||
const w = SCREEN_WIDTH;
|
||||
const h = SCREEN_HEIGHT;
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.4)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 120px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(this.countdown === 0 ? 'GO!' : String(this.countdown), w / 2, h / 2);
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
// ===== BATTLE =====
|
||||
startBattle() {
|
||||
this.state = 'BATTLE';
|
||||
this.energy = 50;
|
||||
this.combo = 0;
|
||||
this.volume = 0;
|
||||
this.ai = new AIOpponent('normal');
|
||||
this.ai.reset();
|
||||
|
||||
this.recorder = new AudioRecorder({
|
||||
sampleRate: 16000,
|
||||
onUpdate: (status) => {
|
||||
this.volume = status.volume;
|
||||
this.combo = status.combo;
|
||||
if (status.isBark && this.state === 'BATTLE') {
|
||||
this.energy = Math.min(100, this.energy + status.pushPower * 100);
|
||||
try {
|
||||
if (wx.vibrateShort) wx.vibrateShort({ type: 'light' });
|
||||
} catch (e) {}
|
||||
}
|
||||
},
|
||||
onError: (err) => {
|
||||
console.error('recorder error', err);
|
||||
wx.showToast({ title: err.message || '麦克风权限失败', icon: 'none', duration: 3000 });
|
||||
}
|
||||
});
|
||||
this.recorder.start();
|
||||
|
||||
this.lastTime = Date.now();
|
||||
this.aniId = requestAnimationFrame(this.loop.bind(this));
|
||||
}
|
||||
|
||||
loop() {
|
||||
if (this.state !== 'BATTLE') return;
|
||||
|
||||
const now = Date.now();
|
||||
const dt = Math.min((now - this.lastTime) / 1000, 0.1);
|
||||
this.lastTime = now;
|
||||
|
||||
const aiAction = this.ai.update(dt);
|
||||
if (aiAction && aiAction.type === 'bark') {
|
||||
this.energy = Math.max(0, this.energy - aiAction.power * 100);
|
||||
}
|
||||
|
||||
if (this.energy >= 100) {
|
||||
this.endBattle(true);
|
||||
return;
|
||||
}
|
||||
if (this.energy <= 0) {
|
||||
this.endBattle(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.drawBattle();
|
||||
this.aniId = requestAnimationFrame(this.loop.bind(this));
|
||||
}
|
||||
|
||||
drawBattle() {
|
||||
const w = SCREEN_WIDTH;
|
||||
const h = SCREEN_HEIGHT;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
if (this.images.bg) {
|
||||
ctx.drawImage(this.images.bg, 0, 0, w, h);
|
||||
} else {
|
||||
ctx.fillStyle = '#FFD97A';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
|
||||
// 返回按钮
|
||||
if (this.images.back) {
|
||||
ctx.drawImage(this.images.back, 10, 10, 70, 24);
|
||||
this.touchAreas = [{
|
||||
x: 10, y: 10, w: 70, h: 24,
|
||||
action: () => this.backToMenu()
|
||||
}];
|
||||
}
|
||||
|
||||
// 能量条
|
||||
const barY = h * 0.55;
|
||||
const barW = w * 0.8;
|
||||
const barH = 24;
|
||||
const barX = (w - barW) / 2;
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.3)';
|
||||
this.roundRect(barX, barY, barW, barH, 12);
|
||||
ctx.fill();
|
||||
|
||||
const fillW = (this.energy / 100) * barW;
|
||||
const gradient = ctx.createLinearGradient(barX, barY, barX + fillW, barY);
|
||||
gradient.addColorStop(0, '#E74C3C');
|
||||
gradient.addColorStop(0.5, '#F39C12');
|
||||
gradient.addColorStop(1, '#3498DB');
|
||||
ctx.fillStyle = gradient;
|
||||
this.roundRect(barX, barY, fillW, barH, 12);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
this.roundRect(barX, barY, barW, barH, 12);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(Math.round(this.energy) + '%', barX + fillW, barY - 8);
|
||||
|
||||
// 玩家狗
|
||||
const playerImg = this.images[`dog_${this.selectedDog}`];
|
||||
if (playerImg) {
|
||||
ctx.drawImage(playerImg, w * 0.12, h * 0.28, 140, 140);
|
||||
}
|
||||
|
||||
// 敌方狗
|
||||
const enemyImg = this.images.dog_pomeranian;
|
||||
if (enemyImg) {
|
||||
ctx.save();
|
||||
ctx.translate(w * 0.88 + 140, h * 0.28);
|
||||
ctx.scale(-1, 1);
|
||||
ctx.drawImage(enemyImg, 0, 0, 140, 140);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// 标签
|
||||
ctx.fillStyle = '#5D4037';
|
||||
ctx.font = 'bold 14px sans-serif';
|
||||
ctx.fillText('我方', w * 0.12 + 70, h * 0.28 + 160);
|
||||
ctx.fillText('敌方', w * 0.88 - 70, h * 0.28 + 160);
|
||||
|
||||
// 音量条
|
||||
const volH = Math.min(this.volume * 100, 80);
|
||||
ctx.fillStyle = `rgba(46, 204, 113, ${0.3 + this.volume * 0.7})`;
|
||||
ctx.fillRect(20, h - 100, 16, -volH);
|
||||
ctx.strokeStyle = '#27AE60';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(20, h - 100, 16, 80);
|
||||
|
||||
// 实时音量显示
|
||||
ctx.fillStyle = '#E74C3C';
|
||||
ctx.font = 'bold 14px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('VOL:' + this.volume.toFixed(2), 50, h - 20);
|
||||
|
||||
if (this.combo > 1) {
|
||||
ctx.fillStyle = '#FF6B00';
|
||||
ctx.font = 'bold 22px sans-serif';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText('连击 x' + this.combo, 50, h - 60);
|
||||
}
|
||||
|
||||
// 提示
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('对着麦克风喊 "汪"!', w / 2, h - 30);
|
||||
}
|
||||
|
||||
// ===== RESULT =====
|
||||
endBattle(isWin) {
|
||||
this.state = 'RESULT';
|
||||
this.isWin = isWin;
|
||||
cancelAnimationFrame(this.aniId);
|
||||
|
||||
if (this.recorder) {
|
||||
this.recorder.stop();
|
||||
this.recorder = null;
|
||||
}
|
||||
|
||||
this.rewardWP = isWin ? (this.combo >= 3 ? 50 : 30) : 5;
|
||||
this.wp = storage.addWP(this.rewardWP);
|
||||
if (isWin) storage.addWin();
|
||||
|
||||
this.drawResult();
|
||||
}
|
||||
|
||||
drawResult() {
|
||||
const w = SCREEN_WIDTH;
|
||||
const h = SCREEN_HEIGHT;
|
||||
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.5)';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
const img = this.isWin ? this.images.victory : this.images.defeat;
|
||||
if (img) {
|
||||
const rw = w * 0.7;
|
||||
const rh = rw * (img.height / img.width);
|
||||
ctx.drawImage(img, (w - rw) / 2, h * 0.15, rw, rh);
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#FFD700';
|
||||
ctx.font = 'bold 36px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText((this.isWin ? '+' : '') + this.rewardWP + ' WP', w / 2, h * 0.55);
|
||||
|
||||
// 按钮
|
||||
this.touchAreas = [];
|
||||
this.drawButton(w / 2 - 110, h * 0.65, 220, 50, '再来一局', '#FF6B00', () => this.startCountdown());
|
||||
this.drawButton(w / 2 - 110, h * 0.75, 220, 50, '返回主页', '#8D6E63', () => this.renderMenu());
|
||||
}
|
||||
|
||||
drawButton(x, y, w, h, text, color, action) {
|
||||
ctx.fillStyle = color;
|
||||
this.roundRect(x, y, w, h, h / 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = 'bold 18px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(text, x + w / 2, y + h / 2);
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
|
||||
this.touchAreas.push({ x, y, w, h, action });
|
||||
}
|
||||
|
||||
backToMenu() {
|
||||
if (this.recorder) {
|
||||
this.recorder.stop();
|
||||
this.recorder = null;
|
||||
}
|
||||
cancelAnimationFrame(this.aniId);
|
||||
this.renderMenu();
|
||||
}
|
||||
|
||||
roundRect(x, y, w, h, r) {
|
||||
const radius = Math.min(r, w / 2, h / 2);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x + radius, y);
|
||||
ctx.lineTo(x + w - radius, y);
|
||||
ctx.arcTo(x + w, y, x + w, y + radius, radius);
|
||||
ctx.lineTo(x + w, y + h - radius);
|
||||
ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius);
|
||||
ctx.lineTo(x + radius, y + h);
|
||||
ctx.arcTo(x, y + h, x, y + h - radius, radius);
|
||||
ctx.lineTo(x, y + radius);
|
||||
ctx.arcTo(x, y, x + radius, y, radius);
|
||||
ctx.closePath();
|
||||
}
|
||||
}
|
||||
9
js/render.js
Normal 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;
|
||||
67
js/storage.js
Normal file
@ -0,0 +1,67 @@
|
||||
const STORAGE_KEYS = {
|
||||
WP: 'wangwang_wp',
|
||||
UNLOCKED: 'wangwang_unlocked',
|
||||
SELECTED_DOG: 'wangwang_selected_dog',
|
||||
TOTAL_WINS: 'wangwang_total_wins'
|
||||
};
|
||||
|
||||
const storage = {
|
||||
getWP() {
|
||||
try {
|
||||
return wx.getStorageSync(STORAGE_KEYS.WP) || 0;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
|
||||
addWP(amount) {
|
||||
const wp = this.getWP() + amount;
|
||||
wx.setStorageSync(STORAGE_KEYS.WP, wp);
|
||||
return wp;
|
||||
},
|
||||
|
||||
getUnlocked() {
|
||||
try {
|
||||
return wx.getStorageSync(STORAGE_KEYS.UNLOCKED) || ['shiba', 'pomeranian'];
|
||||
} catch (e) {
|
||||
return ['shiba', 'pomeranian'];
|
||||
}
|
||||
},
|
||||
|
||||
unlockDog(dogId) {
|
||||
const unlocked = this.getUnlocked();
|
||||
if (!unlocked.includes(dogId)) {
|
||||
unlocked.push(dogId);
|
||||
wx.setStorageSync(STORAGE_KEYS.UNLOCKED, unlocked);
|
||||
}
|
||||
return unlocked;
|
||||
},
|
||||
|
||||
getSelectedDog() {
|
||||
try {
|
||||
return wx.getStorageSync(STORAGE_KEYS.SELECTED_DOG) || 'shiba';
|
||||
} catch (e) {
|
||||
return 'shiba';
|
||||
}
|
||||
},
|
||||
|
||||
setSelectedDog(dogId) {
|
||||
wx.setStorageSync(STORAGE_KEYS.SELECTED_DOG, dogId);
|
||||
},
|
||||
|
||||
getTotalWins() {
|
||||
try {
|
||||
return wx.getStorageSync(STORAGE_KEYS.TOTAL_WINS) || 0;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
|
||||
addWin() {
|
||||
const wins = this.getTotalWins() + 1;
|
||||
wx.setStorageSync(STORAGE_KEYS.TOTAL_WINS, wins);
|
||||
return wins;
|
||||
}
|
||||
};
|
||||
|
||||
export default storage;
|
||||
75
project.config.json
Normal file
@ -0,0 +1,75 @@
|
||||
{
|
||||
"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": "touristappid",
|
||||
"projectname": "wangwang",
|
||||
"condition": {
|
||||
"search": {
|
||||
"current": -1,
|
||||
"list": []
|
||||
},
|
||||
"conversation": {
|
||||
"current": -1,
|
||||
"list": []
|
||||
},
|
||||
"game": {
|
||||
"currentL": -1,
|
||||
"list": []
|
||||
},
|
||||
"miniprogram": {
|
||||
"current": -1,
|
||||
"list": []
|
||||
}
|
||||
},
|
||||
"simulatorPluginLibVersion": {},
|
||||
"packOptions": {
|
||||
"ignore": [
|
||||
{
|
||||
"type": "file",
|
||||
"value": "27d0f61457bd5f9dcb0ed957d988e9d0.apk"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"value": "apk_extracted"
|
||||
},
|
||||
{
|
||||
"type": "folder",
|
||||
"value": "tools"
|
||||
},
|
||||
{
|
||||
"type": "file",
|
||||
"value": "header.jpg"
|
||||
}
|
||||
],
|
||||
"include": []
|
||||
},
|
||||
"isGameTourist": true,
|
||||
"editorSetting": {}
|
||||
}
|
||||
21
project.private.config.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"libVersion": "3.14.3",
|
||||
"projectname": "wangwang",
|
||||
"setting": {
|
||||
"urlCheck": false,
|
||||
"coverView": false,
|
||||
"lazyloadPlaceholderEnable": false,
|
||||
"skylineRenderEnable": false,
|
||||
"preloadBackgroundData": false,
|
||||
"autoAudits": false,
|
||||
"useApiHook": true,
|
||||
"showShadowRootInWxmlPanel": false,
|
||||
"useStaticServer": false,
|
||||
"useLanDebug": false,
|
||||
"showES6CompileOption": false,
|
||||
"compileHotReLoad": false,
|
||||
"bigPackageSizeSupport": false,
|
||||
"checkInvalidKey": true,
|
||||
"ignoreDevUnusedFiles": true
|
||||
}
|
||||
}
|
||||