commit 57aeb989d4d99d94e89df104695a6ecf67a9860b Author: Developer Date: Wed Jun 10 23:18:14 2026 +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..08e0d3b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +27d0f61457bd5f9dcb0ed957d988e9d0.apk +apk_extracted/ +tools/ +header.jpg +.claude/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3fa770a --- /dev/null +++ b/README.md @@ -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 游戏循环 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/wangwang/dogs/corgi_bark.png b/images/wangwang/dogs/corgi_bark.png new file mode 100644 index 0000000..05904b3 Binary files /dev/null and b/images/wangwang/dogs/corgi_bark.png differ diff --git a/images/wangwang/dogs/corgi_idle.png b/images/wangwang/dogs/corgi_idle.png new file mode 100644 index 0000000..4233701 Binary files /dev/null and b/images/wangwang/dogs/corgi_idle.png differ diff --git a/images/wangwang/dogs/golden.png b/images/wangwang/dogs/golden.png new file mode 100644 index 0000000..20fd56d Binary files /dev/null and b/images/wangwang/dogs/golden.png differ diff --git a/images/wangwang/dogs/husky.png b/images/wangwang/dogs/husky.png new file mode 100644 index 0000000..4e4b321 Binary files /dev/null and b/images/wangwang/dogs/husky.png differ diff --git a/images/wangwang/dogs/pomeranian_bark.png b/images/wangwang/dogs/pomeranian_bark.png new file mode 100644 index 0000000..08ece0f Binary files /dev/null and b/images/wangwang/dogs/pomeranian_bark.png differ diff --git a/images/wangwang/dogs/pomeranian_idle.png b/images/wangwang/dogs/pomeranian_idle.png new file mode 100644 index 0000000..c881b6c Binary files /dev/null and b/images/wangwang/dogs/pomeranian_idle.png differ diff --git a/images/wangwang/dogs/shiba.png b/images/wangwang/dogs/shiba.png new file mode 100644 index 0000000..b69f27f Binary files /dev/null and b/images/wangwang/dogs/shiba.png differ diff --git a/images/wangwang/enemies/crab1.png b/images/wangwang/enemies/crab1.png new file mode 100644 index 0000000..46479d7 Binary files /dev/null and b/images/wangwang/enemies/crab1.png differ diff --git a/images/wangwang/enemies/crab2.png b/images/wangwang/enemies/crab2.png new file mode 100644 index 0000000..920347d Binary files /dev/null and b/images/wangwang/enemies/crab2.png differ diff --git a/images/wangwang/ui/back.jpg b/images/wangwang/ui/back.jpg new file mode 100644 index 0000000..8d66a26 Binary files /dev/null and b/images/wangwang/ui/back.jpg differ diff --git a/images/wangwang/ui/bg.jpg b/images/wangwang/ui/bg.jpg new file mode 100644 index 0000000..44f5938 Binary files /dev/null and b/images/wangwang/ui/bg.jpg differ diff --git a/images/wangwang/ui/coin.jpg b/images/wangwang/ui/coin.jpg new file mode 100644 index 0000000..e1022c0 Binary files /dev/null and b/images/wangwang/ui/coin.jpg differ diff --git a/images/wangwang/ui/defeat.jpg b/images/wangwang/ui/defeat.jpg new file mode 100644 index 0000000..2a46d19 Binary files /dev/null and b/images/wangwang/ui/defeat.jpg differ diff --git a/images/wangwang/ui/frame.jpg b/images/wangwang/ui/frame.jpg new file mode 100644 index 0000000..551f5d8 Binary files /dev/null and b/images/wangwang/ui/frame.jpg differ diff --git a/images/wangwang/ui/hp_enemy.jpg b/images/wangwang/ui/hp_enemy.jpg new file mode 100644 index 0000000..8c018b7 Binary files /dev/null and b/images/wangwang/ui/hp_enemy.jpg differ diff --git a/images/wangwang/ui/hp_heart.jpg b/images/wangwang/ui/hp_heart.jpg new file mode 100644 index 0000000..c64c1f0 Binary files /dev/null and b/images/wangwang/ui/hp_heart.jpg differ diff --git a/images/wangwang/ui/hp_player.jpg b/images/wangwang/ui/hp_player.jpg new file mode 100644 index 0000000..3438718 Binary files /dev/null and b/images/wangwang/ui/hp_player.jpg differ diff --git a/images/wangwang/ui/lock.jpg b/images/wangwang/ui/lock.jpg new file mode 100644 index 0000000..7558ed1 Binary files /dev/null and b/images/wangwang/ui/lock.jpg differ diff --git a/images/wangwang/ui/start.jpg b/images/wangwang/ui/start.jpg new file mode 100644 index 0000000..1d6ef53 Binary files /dev/null and b/images/wangwang/ui/start.jpg differ diff --git a/images/wangwang/ui/victory.jpg b/images/wangwang/ui/victory.jpg new file mode 100644 index 0000000..946b3e5 Binary files /dev/null and b/images/wangwang/ui/victory.jpg differ diff --git a/js/ai.js b/js/ai.js new file mode 100644 index 0000000..aa9b394 --- /dev/null +++ b/js/ai.js @@ -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); + } +} diff --git a/js/audioRecorder.js b/js/audioRecorder.js new file mode 100644 index 0000000..c17f1b6 --- /dev/null +++ b/js/audioRecorder.js @@ -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(); + } +} diff --git a/js/barkDetector.js b/js/barkDetector.js new file mode 100644 index 0000000..4e496cd --- /dev/null +++ b/js/barkDetector.js @@ -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 + }; + } +} diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..9e80487 --- /dev/null +++ b/js/main.js @@ -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(); + } +} 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/storage.js b/js/storage.js new file mode 100644 index 0000000..ff8be54 --- /dev/null +++ b/js/storage.js @@ -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; diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..7d94ba2 --- /dev/null +++ b/project.config.json @@ -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": {} +} \ No newline at end of file diff --git a/project.private.config.json b/project.private.config.json new file mode 100644 index 0000000..7540082 --- /dev/null +++ b/project.private.config.json @@ -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 + } +} \ No newline at end of file