Initial commit: 汪汪大作战微信小游戏

This commit is contained in:
Developer 2026-06-10 23:18:14 +08:00
commit 57aeb989d4
33 changed files with 1112 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: {},
}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
27d0f61457bd5f9dcb0ed957d988e9d0.apk
apk_extracted/
tools/
header.jpg
.claude/

36
README.md Normal file
View 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 游戏循环

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
images/wangwang/ui/back.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
images/wangwang/ui/bg.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
images/wangwang/ui/coin.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

BIN
images/wangwang/ui/lock.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

54
js/ai.js Normal file
View 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
View 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
View 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
View 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
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;

67
js/storage.js Normal file
View 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
View 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": {}
}

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