first commit

This commit is contained in:
jakciehan
2026-04-10 22:59:39 +08:00
commit cc2e7b9bb0
89 changed files with 23631 additions and 0 deletions
Vendored
BIN
View File
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"containers":[],"config":{}}
@@ -0,0 +1,220 @@
# 需求文档:坦克大战 — 极简商业化
## 引言
本文档基于《坦克大战经典游戏 - 极简商业化方案》,提炼可落地的商业化功能需求。核心原则是 **"轻数值、重体验、做减法"**,坚持不破坏经典坦克大战"一发子弹消灭一个敌人"的爽快感。
商业化策略采用 **IAA(激励广告)+ 极简IAP(内购)** 双轨模式:
- **唯一货币**:金币(Gold),砍掉钻石、赛季币等复杂体系
- **核心变现**:复活续关(广告/金币)、局前Buff(金币消耗)、去广告特权(唯一内购)
- **经济闭环**:玩游戏 → 死亡/想变强 → 看广告赚金币 → 购买复活/Buff → 继续游戏
与之前的复杂商业化方案相比,本方案**砍掉**了:钻石/赛季币货币、体力系统、皮肤商店、战斗通行证、月卡、社交裂变、付费引导等重型系统。
---
## 需求
### 需求 1:金币货币系统
**用户故事:** 作为一名玩家,我希望游戏只有一种简单的货币(金币),以便我能清晰地理解如何获取和使用资源,不被复杂的货币体系困扰。
#### 验收标准
##### 1.1 金币获取
1. WHEN 玩家完成一局对战(通关或失败结算) THEN 系统 SHALL 根据对局表现发放基础金币奖励(基础值50金币)。
2. WHEN 玩家在结算界面选择观看广告 THEN 系统 SHALL 将本局金币奖励翻倍(即额外获得与基础奖励等额的金币)。
3. WHEN 玩家在主界面点击"领金币"按钮并观看广告 THEN 系统 SHALL 发放100金币,每日上限3次。
##### 1.2 金币消耗
4. WHEN 玩家在死亡时选择金币复活 THEN 系统 SHALL 扣除200金币。
5. WHEN 玩家在局前购买Buff THEN 系统 SHALL 扣除对应金币(护盾100金币、双倍火力150金币)。
6. IF 玩家金币余额不足以支付消耗 THEN 系统 SHALL 提示余额不足,并引导至广告赚金币或金币包购买。
##### 1.3 金币持久化
7. WHEN 金币余额发生变动 THEN 系统 SHALL 通过 StorageManager 持久化金币数据,并同步至云端。
8. WHEN 玩家换设备登录 THEN 系统 SHALL 从云端恢复金币余额。
9. 系统 SHALL 设置金币上限为999,999,防止数值溢出。
---
### 需求 2:复活续关系统(核心变现点)
**用户故事:** 作为一名玩家,我希望在坦克被击毁时有机会复活继续游戏,以便不浪费已有的关卡进度。
#### 验收标准
##### 2.1 复活选项
1. WHEN 玩家坦克被击毁且生命数降为0 THEN 系统 SHALL 弹出复活选择弹窗,提供两个选项:观看广告复活(免费)、花费200金币复活。
2. WHEN 玩家选择观看广告并完整观看激励视频 THEN 系统 SHALL 立即复活玩家,保留当前关卡进度。
3. WHEN 玩家选择花费金币复活且余额≥200 THEN 系统 SHALL 扣除200金币并立即复活玩家。
4. IF 玩家选择放弃复活 THEN 系统 SHALL 正常进入游戏失败结算流程。
##### 2.2 复活限制
5. WHEN 同一局游戏中玩家已使用过1次复活(无论广告或金币) THEN 系统 SHALL 不再提供复活选项,直接进入失败结算。
6. IF 广告加载失败 THEN 系统 SHALL 仅展示金币复活选项,隐藏广告复活按钮。
---
### 需求 3:局前Buff系统
**用户故事:** 作为一名玩家,我希望在开局前能购买一次性增益道具,以便在困难关卡中获得额外优势。
#### 验收标准
##### 3.1 Buff商店
1. WHEN 玩家在关卡加载前(选关/匹配后、正式开局前) THEN 系统 SHALL 展示局前Buff购买界面。
2. 系统 SHALL 提供以下Buff道具供购买:
- **护盾**(100金币):开局自带一层护盾,可抵挡一次伤害。
- **双倍火力**(150金币):开局10秒内子弹威力翻倍。
3. WHEN 玩家选择购买Buff且金币余额充足 THEN 系统 SHALL 扣除金币并标记该Buff在本局生效。
4. WHEN 玩家选择跳过 THEN 系统 SHALL 正常开始游戏,不施加任何Buff。
##### 3.2 Buff生效
5. WHEN 本局开始且玩家已购买护盾Buff THEN 系统 SHALL 为玩家坦克添加一层护盾效果(视觉+逻辑),受到第一次伤害时消耗护盾而非扣除生命。
6. WHEN 本局开始且玩家已购买双倍火力Buff THEN 系统 SHALL 在开局10秒内将玩家子弹威力设为2倍,10秒后恢复正常。
7. WHEN 本局结束(无论胜负) THEN 系统 SHALL 清除所有Buff效果,Buff不跨局保留。
---
### 需求 4:广告系统
**用户故事:** 作为游戏运营方,我希望在合适的场景嵌入广告,以便在不破坏玩家体验的前提下获得广告收入。
#### 验收标准
##### 4.1 激励视频广告
1. WHEN 关卡加载时 THEN 系统 SHALL 预加载激励视频广告资源,减少玩家等待时间。
2. WHEN 广告播放完毕 THEN 系统 SHALL 在广告结束瞬间立即发放对应奖励。
3. WHEN 同一广告场景在15分钟内已展示过 THEN 系统 SHALL 不重复展示相同场景的广告。
4. IF 广告加载失败 THEN 系统 SHALL 提示"广告暂时不可用",不阻塞玩家正常流程。
##### 4.2 插屏广告
5. WHEN 玩家完成一局游戏退出关卡时 THEN 系统 SHALL 检查是否满足插屏广告展示条件。
6. IF 距离上次插屏广告展示已超过3局 THEN 系统 SHALL 展示一次插屏广告。
7. IF 玩家已购买"去广告特权" THEN 系统 SHALL 永久跳过所有插屏广告。
8. WHEN 插屏广告加载失败 THEN 系统 SHALL 静默跳过,不影响玩家正常流程。
##### 4.3 每日领金币广告
9. WHEN 玩家在主界面点击"领金币"按钮 THEN 系统 SHALL 播放激励视频广告。
10. WHEN 广告播放完毕 THEN 系统 SHALL 发放100金币。
11. WHEN 玩家当日已领取3次 THEN 系统 SHALL 将"领金币"按钮置灰,提示"明日再来"。
##### 4.4 双倍结算广告
12. WHEN 玩家通关进入结算界面 THEN 系统 SHALL 展示"观看广告获得双倍金币"按钮。
13. WHEN 玩家完整观看激励视频 THEN 系统 SHALL 将本局获得的金币翻倍发放。
14. IF 广告加载失败 THEN 系统 SHALL 提示"广告暂时不可用",按正常倍率发放。
---
### 需求 5:去广告特权(唯一内购)
**用户故事:** 作为一名核心玩家,我希望能一次性付费永久去除广告打断,以便获得更流畅的游戏体验。
#### 验收标准
1. WHEN 玩家在设置或商店中购买"去广告特权"(¥18/永久) THEN 系统 SHALL 通过微信支付完成交易。
2. WHEN 购买成功 THEN 系统 SHALL 永久移除所有插屏广告。
3. IF 玩家已购买去广告特权 THEN 系统 SHALL 保留激励视频广告入口(复活广告、双倍结算广告、每日领金币广告),因为这些是玩家主动选择观看以获取奖励。
4. IF 玩家已购买去广告特权且在复活弹窗中 THEN 系统 SHALL 仍然展示"观看广告复活"选项(玩家自愿选择)。
5. WHEN 购买记录 THEN 系统 SHALL 同步至云端,确保换设备后特权不丢失。
6. IF 支付过程中网络中断 THEN 系统 SHALL 在网络恢复后自动查询订单状态,补发未到账的特权。
---
### 需求 6:金币包与新手礼包
**用户故事:** 作为一名不想看广告但愿意付费的玩家,我希望能直接购买金币,以便快速获得复活和Buff所需的资源。
#### 验收标准
##### 6.1 金币充值包
1. WHEN 玩家在商店中购买金币包 THEN 系统 SHALL 按以下规格发放金币:¥6 = 1000金币。
2. WHEN 购买成功 THEN 系统 SHALL 立即将金币添加到玩家账户。
3. WHEN 购买记录 THEN 系统 SHALL 同步至云端。
##### 6.2 新手礼包
4. WHEN 新用户首次进入游戏 THEN 系统 SHALL 在24小时内展示新手礼包购买入口(¥1 = 500金币,相当于送一次复活+一次Buff)。
5. WHEN 新手礼包倒计时结束(24小时) THEN 系统 SHALL 移除新手礼包购买入口,不再展示。
6. WHEN 新手礼包购买成功 THEN 系统 SHALL 立即发放500金币。
##### 6.3 支付安全
7. WHEN 玩家发起内购 THEN 系统 SHALL 通过微信支付(`wx.requestMidasPayment`)完成交易。
8. IF 支付过程中网络中断 THEN 系统 SHALL 在网络恢复后自动查询订单状态,补发未到账商品。
9. WHEN 购买虚拟商品 THEN 系统 SHALL 将购买记录同步至云端,确保换设备后不丢失。
---
### 需求 7:主界面商业化入口
**用户故事:** 作为一名玩家,我希望在主界面能方便地找到商店和领金币入口,以便快速获取资源。
#### 验收标准
1. WHEN 玩家进入主菜单 THEN 系统 SHALL 在界面上展示"领金币"按钮,显示今日剩余领取次数(如"3/3")。
2. WHEN 玩家进入主菜单 THEN 系统 SHALL 展示"商店"按钮,点击后进入商店界面。
3. WHEN 玩家进入商店界面 THEN 系统 SHALL 展示以下内容:
- 当前金币余额
- 去广告特权购买入口(已购买则显示"已拥有")
- 金币充值包购买入口
- 新手礼包入口(仅限新用户24小时内可见)
4. WHEN 玩家金币余额显示 THEN 系统 SHALL 在主界面顶部常驻显示当前金币数量。
---
### 需求 8:合规与安全
**用户故事:** 作为游戏运营方,我希望游戏符合平台合规要求,以便合法运营且风险最低。
#### 验收标准
##### 8.1 基础合规
1. WHEN 系统识别到未成年用户 THEN 系统 SHALL 限制每日广告展示不超过5次。
2. WHEN 未成年用户进行消费 THEN 系统 SHALL 限制月消费上限为¥400,单次消费超过¥50时弹出确认提示。
##### 8.2 反作弊
3. WHEN 系统检测到同一广告源IP短时间内大量请求 THEN 系统 SHALL 触发广告反刷机制。
4. WHEN 系统检测到异常大额充值行为 THEN 系统 SHALL 触发人工审核标记。
---
## 边界情况与技术约束
### 边界情况
1. **广告加载失败**:激励视频加载失败时,复活弹窗仅展示金币选项;结算双倍按钮提示稍后重试。
2. **支付异常**:支付中断后自动查询订单状态并补发商品。
3. **跨设备同步**:金币余额、去广告特权、购买记录均需云端同步。
4. **金币溢出**:金币上限999,999,达到上限后不再发放(提示已满)。
5. **Buff叠加**:护盾和双倍火力可同时购买,互不冲突。
### 技术约束
1. 微信小游戏内购需通过 `wx.requestMidasPayment` 接口完成。
2. 广告SDK使用微信小游戏广告组件(`wx.createRewardedVideoAd``wx.createInterstitialAd`)。
3. 金币数据需服务端校验,防止客户端篡改。
4. 未成年人识别依赖微信平台提供的用户年龄信息接口。
### 成功标准
1. 激励视频广告转化率 ≥ 8%(复活场景)。
2. 去广告特权购买率 ≥ 2%。
3. 广告展示不影响核心游戏体验(玩家满意度 ≥ 4/5)。
4. 所有内购流程零丢单率。
---
## 与旧方案的差异说明
本极简方案相比之前的复杂商业化方案,**砍掉**了以下系统:
- ❌ 钻石货币、赛季币货币(仅保留金币)
- ❌ 体力系统(StaminaManager
- ❌ 皮肤商店系统(SkinManager、SkinData、ShopScene中的皮肤部分)
- ❌ 战斗通行证系统(BattlePassManager、BattlePassData、BattlePassScene
- ❌ 月卡系统
- ❌ 社交裂变系统(ShareManager扩展部分)
- ❌ 付费引导系统(PromotionManager
- ❌ 复杂的合规系统(ComplianceManager大部分功能)
**新增**了以下功能:
- ✅ 局前Buff系统(护盾、双倍火力)
- ✅ 每日领金币广告(主界面入口)
- ✅ 金币复活选项(除广告外的第二复活途径)
- ✅ 金币充值包(¥6=1000金币)
@@ -0,0 +1,128 @@
# 实施计划:坦克大战 — 极简商业化
> **前置说明**:项目中已存在旧版复杂商业化代码(V1.0~V2.5),本计划需要先清理废弃模块,再重构保留模块,最后新建功能。
## 现有代码盘点
### 需要删除的文件(旧方案废弃)
- `js/managers/StaminaManager.js` — 体力系统(砍掉)
- `js/managers/SkinManager.js` — 皮肤管理器(砍掉)
- `js/managers/BattlePassManager.js` — 战斗通行证(砍掉)
- `js/managers/PromotionManager.js` — 付费引导(砍掉)
- `js/managers/ShareManager.js` — 社交裂变扩展(砍掉)
- `js/data/SkinData.js` — 皮肤数据(砍掉)
- `js/data/BattlePassData.js` — 通行证数据(砍掉)
- `js/scenes/BattlePassScene.js` — 通行证场景(砍掉)
### 需要重构的文件(保留但大幅简化)
- `js/managers/CurrencyManager.js` — 简化为仅金币,去掉钻石/赛季币
- `js/managers/PaymentManager.js` — 简化为去广告+金币包+新手礼包
- `js/managers/AdManager.js` — 保留核心逻辑,增加每日领金币广告场景
- `js/managers/ComplianceManager.js` — 简化为基础合规
- `js/scenes/ShopScene.js` — 重写为极简商店(去广告+金币包+新手礼包)
- `js/scenes/GameScene.js` — 调整复活弹窗(增加金币复活选项)、集成Buff生效逻辑
- `js/scenes/ResultScene.js` — 调整结算金币发放逻辑
- `js/scenes/TeamResultScene.js` — 同步调整结算金币发放
- `js/scenes/MenuScene.js` — 调整按钮布局(去掉通行证入口,增加领金币按钮)
### 需要新建的文件
- `js/managers/BuffManager.js` — 局前Buff管理器(新功能)
- `js/scenes/BuffSelectScene.js` — 局前Buff选择界面(新功能)
---
## 任务清单
- [ ] 1. 清理废弃模块与引用
- 删除以下文件:`StaminaManager.js``SkinManager.js``BattlePassManager.js``PromotionManager.js``ShareManager.js``SkinData.js``BattlePassData.js``BattlePassScene.js`
- 清理 `game.js` 中对上述模块的 import 和注册代码
- 清理 `GameGlobal.js` 中废弃的场景常量(BATTLE_PASS、SHOP 相关的旧定义)
- 清理 `MenuScene.js` 中通行证入口按钮的代码
- 清理 `GameScene.js` 中体力检查相关代码
- 清理 `Tank.js` / `PlayerTank.js` 中皮肤渲染相关代码
- 清理 `ResultScene.js` / `TeamResultScene.js` 中通行证任务上报代码
- 清理 `zh.js` / `en.js` 中废弃模块的国际化文案
- _需求:与旧方案的差异说明_
- [ ] 2. 重构 CurrencyManager — 仅金币货币
- 移除钻石(diamond)和赛季币(seasonCoin)相关的所有属性和方法
- 保留金币(gold)的 `add``spend``getBalance` 方法
- 设置金币上限 999,999`add` 时检查溢出
- 确保 `spend` 方法在余额不足时返回失败并触发事件
- 保留 StorageManager 持久化和云端同步逻辑
- _需求:1.1、1.2、1.3_
- [ ] 3. 重构 AdManager — 增加每日领金币广告场景
- 保留现有的激励视频(复活、双倍结算)和插屏广告核心逻辑
- 新增 `AD_SCENE.DAILY_GOLD` 广告场景枚举
- 新增 `showDailyGoldAd()` 方法,播放完毕后触发 `daily_gold_reward` 事件
- 新增每日领取次数追踪(每日上限3次,跨天重置)
- 新增 `getDailyGoldRemaining()` 方法返回今日剩余次数
- 保留15分钟频控和预加载机制
- _需求:4.1、4.2、4.3、4.4_
- [ ] 4. 重构 PaymentManager — 极简内购
- 移除月卡、钻石包、皮肤礼包等商品配置
- 仅保留三个商品:去广告特权(¥18)、金币包(¥6=1000金币)、新手礼包(¥1=500金币)
- 去广告特权购买后设置永久标记,通过 StorageManager 持久化
- 新手礼包增加24小时倒计时逻辑(首次进入游戏开始计时,超时后不可购买)
- 保留微信支付(`wx.requestMidasPayment`)和掉单恢复逻辑
- _需求:5.1~5.6、6.1~6.3_
- [ ] 5. 重构 GameScene — 复活弹窗双选项 + Buff生效
- 修改复活弹窗:从仅"广告复活"改为"广告复活 + 金币复活(200金币)"双选项
- 金币复活调用 CurrencyManager.spend(200),余额不足时按钮置灰
- 保留每局仅限1次复活的限制
- 广告加载失败时隐藏广告按钮,仅展示金币复活
- 集成 BuffManager:开局时检查已购Buff并施加效果(护盾、双倍火力)
- 移除旧的体力检查逻辑(如有)
- _需求:2.1~2.2、3.2_
- [ ] 6. 创建 BuffManager — 局前Buff管理器
- 定义 Buff 类型枚举:`SHIELD`(护盾,100金币)、`DOUBLE_FIRE`(双倍火力,150金币)
- 实现 `purchaseBuff(type)` 方法:检查金币余额 → 扣费 → 标记本局Buff
- 实现 `getActiveBuffs()` 返回当前局已购买的Buff列表
- 实现 `clearBuffs()` 在每局结束时清除所有Buff
- 实现护盾逻辑:为 PlayerTank 添加 `shield` 属性,受击时优先消耗护盾
- 实现双倍火力逻辑:开局10秒内子弹威力×2,到期后恢复
- _需求:3.1、3.2_
- [ ] 7. 创建 BuffSelectScene — 局前Buff选择界面
- 在关卡加载前(GameScene 初始化前)展示Buff选择界面
- 展示两个Buff卡片:护盾(100金币)、双倍火力(150金币),显示当前金币余额
- 点击购买时调用 BuffManager.purchaseBuff(),余额不足时提示并引导
- 提供"跳过"按钮直接进入游戏
- 护盾和双倍火力可同时购买
- 购买完成或跳过后,切换到 GameScene 开始游戏
- _需求:3.1.1~3.1.4、7.4_
- [ ] 8. 重构 ShopScene — 极简商店界面
- 重写商店界面,移除皮肤商店相关内容
- 顶部显示当前金币余额
- 展示三个商品卡片:去广告特权(¥18,已购显示"已拥有")、金币包(¥6=1000金币)、新手礼包(¥1=500金币,24小时倒计时)
- 点击购买调用 PaymentManager 对应方法
- 新手礼包超过24小时后自动隐藏
- _需求:5.1~5.2、6.1~6.2、7.3_
- [ ] 9. 重构 MenuScene — 主界面商业化入口
- 移除通行证入口按钮
- 新增"领金币"按钮,显示今日剩余次数(如"🪙 领金币 3/3"),点击调用 AdManager.showDailyGoldAd()
- 当日次数用完后按钮置灰,显示"明日再来"
- 保留"商店"按钮入口
- 顶部常驻显示当前金币数量
- 调整按钮布局确保所有按钮可见
- _需求:7.1~7.4、4.3_
- [ ] 10. 重构 ResultScene / TeamResultScene — 结算金币发放
- 结算时计算基础金币奖励(基础值50,可根据表现浮动)
- 展示"观看广告双倍金币"按钮,观看后翻倍发放
- 调用 CurrencyManager.add() 发放金币
- 移除通行证任务进度上报代码
- 保留插屏广告展示逻辑(每3局一次,去广告特权跳过)
- _需求:1.1.1~1.1.2、4.4、4.2_
- [ ] 11. 简化 ComplianceManager + 注册管理器 + 国际化
- 简化 ComplianceManager:仅保留未成年人广告次数限制(每日≤5次)和消费限制(月≤¥400)
-`game.js` 中注册 BuffManager,移除废弃管理器的注册
- 更新 `zh.js` / `en.js`:添加局前Buff、领金币、极简商店相关文案,移除废弃文案
- _需求:8.1~8.2_
@@ -0,0 +1,273 @@
# 商业化需求文档:坦克大作战
## 引言
本文档基于《坦克大作战》微信小游戏商业化方案,提炼出可落地的商业化功能需求。商业化总策略采用 **"IAA(激励广告)+ IAP(内购)+ 社交裂变"** 三轨并行模式,以**非强制性、高转化**为核心原则,确保免费玩家体验不受损害,同时激励付费转化。
商业化功能按版本分阶段交付:
- **V1.0 基础版**:激励视频广告(复活、双倍结算)、插屏广告
- **V1.5 内购版**:钻石充值、去广告特权、基础皮肤商店
- **V2.0 赛季版**:战斗通行证、赛季任务、段位系统
- **V2.5 社交版**:分享裂变体系、战队系统、社交皮肤
---
## 需求
### 需求 1:激励视频广告系统
**用户故事:** 作为游戏运营方,我希望在关键游戏节点嵌入激励视频广告,以便在不破坏玩家体验的前提下获得广告收入。
#### 验收标准
##### 1.1 复活续关广告
1. WHEN 玩家在关卡中死亡且生命数降为0 THEN 系统 SHALL 弹出"观看广告复活"选项,展示激励视频广告入口。
2. WHEN 玩家选择观看广告并完整观看激励视频 THEN 系统 SHALL 立即复活玩家,保留当前火力等级,在出生点重生。
3. IF 玩家选择不观看广告 THEN 系统 SHALL 正常进入游戏失败结算流程。
4. WHEN 同一关卡中玩家已使用过1次广告复活 THEN 系统 SHALL 不再提供复活广告选项(每关最多复活1次)。
##### 1.2 双倍结算广告
5. WHEN 玩家通关进入结算界面 THEN 系统 SHALL 展示"观看广告获得双倍奖励"按钮。
6. WHEN 玩家完整观看激励视频 THEN 系统 SHALL 将本局获得的金币和经验值翻倍发放。
7. IF 广告加载失败 THEN 系统 SHALL 提示"广告暂时不可用,请稍后重试",并按正常倍率发放奖励。
##### 1.3 宝箱加速广告
8. WHEN 玩家获得稀有宝箱且宝箱处于冷却倒计时中(4小时) THEN 系统 SHALL 展示"观看广告立即开启"按钮。
9. WHEN 玩家完整观看激励视频 THEN 系统 SHALL 跳过冷却时间,立即打开宝箱并发放奖励。
##### 1.4 体力恢复广告
10. WHEN 玩家体力耗尽 THEN 系统 SHALL 展示"观看广告恢复体力"按钮。
11. WHEN 玩家完整观看激励视频 THEN 系统 SHALL 恢复5点体力。
12. WHEN 玩家当日已通过广告恢复体力达5次 THEN 系统 SHALL 隐藏该广告入口,提示"今日广告恢复次数已用完"。
##### 1.5 免费礼包广告
13. WHEN 玩家进入每日签到或活动页面 THEN 系统 SHALL 展示"观看广告领取免费礼包"入口。
14. WHEN 玩家完整观看激励视频 THEN 系统 SHALL 发放随机道具包(内容根据配置表随机)。
##### 1.6 广告体验优化
15. WHEN 关卡加载时 THEN 系统 SHALL 预加载激励视频广告资源,减少玩家等待时间。
16. WHEN 同一广告场景在15分钟内已展示过 THEN 系统 SHALL 不重复展示相同场景的广告。
17. WHEN 广告播放完毕 THEN 系统 SHALL 在广告结束瞬间立即发放奖励,建立正反馈。
---
### 需求 2:插屏广告
**用户故事:** 作为游戏运营方,我希望在自然间歇点展示插屏广告,以便补充广告收入。
#### 验收标准
1. WHEN 玩家完成一局游戏(胜利或失败)退出关卡时 THEN 系统 SHALL 检查是否满足插屏广告展示条件。
2. IF 距离上次插屏广告展示已超过3局 THEN 系统 SHALL 展示一次插屏广告。
3. IF 玩家已购买"去广告特权" THEN 系统 SHALL 永久跳过所有插屏广告展示。
4. WHEN 插屏广告加载失败 THEN 系统 SHALL 静默跳过,不影响玩家正常流程。
---
### 需求 3:货币体系
**用户故事:** 作为一名玩家,我希望游戏有清晰的货币体系,以便了解如何获取和使用游戏内资源。
#### 验收标准
##### 3.1 金币系统
1. WHEN 玩家完成对局 THEN 系统 SHALL 根据击杀数、通关时间等计算并发放金币奖励。
2. WHEN 玩家完成每日任务 THEN 系统 SHALL 发放对应的金币奖励。
3. WHEN 玩家消耗金币 THEN 系统 SHALL 支持以下消耗场景:升级坦克属性、购买基础道具。
4. WHEN 系统展示金币相关信息 THEN 系统 SHALL 按照 100金币 ≈ ¥0.1 的价值锚定进行经济平衡。
##### 3.2 钻石系统
5. WHEN 玩家通过充值获得钻石 THEN 系统 SHALL 按照购买的钻石包规格发放对应数量的钻石。
6. WHEN 玩家通过高级通行证或活动获得钻石 THEN 系统 SHALL 发放对应数量的钻石。
7. WHEN 玩家消耗钻石 THEN 系统 SHALL 支持以下消耗场景:购买皮肤、购买稀有道具、补充体力。
8. WHEN 系统展示钻石相关信息 THEN 系统 SHALL 按照 1钻石 ≈ ¥0.1 的价值锚定进行经济平衡。
##### 3.3 赛季币系统
9. WHEN 玩家完成赛季任务或达到段位奖励节点 THEN 系统 SHALL 发放赛季币。
10. WHEN 玩家消耗赛季币 THEN 系统 SHALL 支持兑换往季限定皮肤。
11. WHEN 赛季结束 THEN 系统 SHALL 保留玩家的赛季币余额,不清零(可跨赛季使用)。
---
### 需求 4:应用内购买(IAP)系统
**用户故事:** 作为一名玩家,我希望能通过内购获得增值服务和虚拟商品,以便提升游戏体验。
#### 验收标准
##### 4.1 去广告特权
1. WHEN 玩家在商店中购买"去广告特权"(¥30/永久) THEN 系统 SHALL 永久移除所有插屏广告。
2. IF 玩家已购买去广告特权 THEN 系统 SHALL 保留激励视频广告入口(因为是玩家主动选择观看以获取奖励)。
3. IF 玩家已购买去广告特权 THEN 系统 SHALL 在原插屏广告触发点直接跳过,无任何等待。
##### 4.2 月卡
4. WHEN 玩家购买月卡(¥12/月) THEN 系统 SHALL 立即发放月卡权益:专属头像框解锁。
5. WHEN 月卡有效期内玩家每日登录 THEN 系统 SHALL 发放100钻石的每日领取奖励。
6. WHEN 月卡到期 THEN 系统 SHALL 停止发放每日钻石,收回专属头像框(或标记为过期状态)。
7. IF 玩家开启自动续费 THEN 系统 SHALL 在到期前自动续费,前3天支持无条件退款。
##### 4.3 钻石充值包
8. WHEN 玩家购买钻石包 THEN 系统 SHALL 按以下规格发放钻石:¥6/60钻、¥30/360钻、¥68/880钻。
9. WHEN 玩家首次充值任意金额 THEN 系统 SHALL 额外赠送等值钻石(首充双倍)。
10. WHEN 玩家连续7天每日充值 THEN 系统 SHALL 在第7天额外赠送稀有皮肤奖励。
##### 4.4 皮肤礼包
11. WHEN 玩家购买皮肤礼包(¥18-68 THEN 系统 SHALL 解锁对应的限定坦克皮肤及配套技能特效。
12. WHEN 赛季更新或节日活动期间 THEN 系统 SHALL 上架对应主题的限定皮肤礼包。
##### 4.5 新手礼包
13. WHEN 新用户首次进入游戏 THEN 系统 SHALL 在24小时内展示新手礼包购买入口(¥1,价值¥30道具组合)。
14. WHEN 新手礼包倒计时结束(24小时) THEN 系统 SHALL 移除新手礼包购买入口,不再展示。
##### 4.6 支付与安全
15. WHEN 玩家发起内购 THEN 系统 SHALL 通过微信支付完成交易,交易成功后立即发放商品。
16. IF 支付过程中网络中断 THEN 系统 SHALL 在网络恢复后自动查询订单状态,补发未到账的商品。
17. WHEN 玩家购买的虚拟商品 THEN 系统 SHALL 将购买记录同步至云端,确保换设备后不丢失。
---
### 需求 5:战斗通行证(Battle Pass)系统
**用户故事:** 作为一名玩家,我希望通过完成任务解锁赛季奖励,以便获得持续的游戏目标和丰厚的回报。
#### 验收标准
##### 5.1 赛季基础设计
1. WHEN 新赛季开始 THEN 系统 SHALL 重置通行证等级为1级,赛季时长为28天(4周)。
2. WHEN 赛季进行中 THEN 系统 SHALL 为所有玩家提供免费通行证(20级),包含基础奖励(金币、普通皮肤)。
3. WHEN 玩家购买高级通行证(¥18/赛季) THEN 系统 SHALL 解锁40级奖励轨道,包含:3款限定坦克皮肤、专属头像框、聊天气泡、双倍任务经验加成、赛季结算额外30%金币。
4. WHEN 玩家获得通行证经验 THEN 系统 SHALL 更新通行证等级进度条,达到新等级时自动发放对应奖励。
##### 5.2 任务体系
5. WHEN 每日刷新时 THEN 系统 SHALL 为免费玩家生成3个每日任务(100经验/个),为高级通行证玩家额外生成2个每日任务。
6. WHEN 每周刷新时 THEN 系统 SHALL 为免费玩家生成5个每周任务(500经验/个),为高级通行证玩家额外生成3个每周任务。
7. WHEN 赛季开始时 THEN 系统 SHALL 生成10个赛季成就(1000经验/个),免费版和高级版无差异。
8. WHEN 玩家完成任务 THEN 系统 SHALL 立即发放对应经验值,并更新通行证进度。
##### 5.3 转化策略
9. WHEN 免费玩家通行证等级达到10级 THEN 系统 SHALL 展示高级通行证的前10级奖励预览(已解锁但需购买高级版才能领取)。
10. WHEN 赛季剩余时间不足3天 THEN 系统 SHALL 展示高级通行证限时8折优惠。
11. WHEN 玩家将通行证分享给3位好友 THEN 系统 SHALL 发放5折优惠券(可用于购买高级通行证)。
---
### 需求 6:社交裂变与分享变现
**用户故事:** 作为游戏运营方,我希望通过社交分享激励体系降低获客成本,以便利用微信社交链实现用户增长。
#### 验收标准
##### 6.1 分享激励
1. WHEN 玩家每日首次分享游戏 THEN 系统 SHALL 发放50金币奖励。
2. WHEN 玩家成功邀请新用户(新用户需完成新手引导) THEN 系统 SHALL 发放200金币奖励给邀请者,每日上限5人。
3. WHEN 被邀请的新用户完成新手引导 THEN 系统 SHALL 发放双倍经验卡(3天有效期)给新用户。
4. WHEN 玩家分享战绩 THEN 系统 SHALL 以概率发放稀有道具奖励,每日上限3次。
5. WHEN 玩家与好友组队完成3局对战(每局时长>2分钟) THEN 系统 SHALL 发放100钻石给双方。
##### 6.2 防作弊机制
6. WHEN 系统判定分享奖励时 THEN 系统 SHALL 进行 IP + 设备指纹去重,同一设备/IP不重复计算。
7. WHEN 系统判定邀请新用户奖励时 THEN 系统 SHALL 验证新用户已完成新手引导,未完成则不发放邀请者奖励。
##### 6.3 裂变活动
8. WHEN 运营配置"老带新活动"时 THEN 系统 SHALL 支持:邀请3位新用户送永久限定皮肤。
9. WHEN 运营配置"战队招募活动"时 THEN 系统 SHALL 支持:创建战队并招募10人,队长得500钻石。
10. WHEN 运营配置"节日助力活动"时 THEN 系统 SHALL 支持:集齐5种道具可兑换大奖,道具需好友互赠。
---
### 需求 7:皮肤商店系统
**用户故事:** 作为一名玩家,我希望能购买和装备不同的坦克皮肤,以便展示个性和获得视觉享受。
#### 验收标准
1. WHEN 玩家进入皮肤商店 THEN 系统 SHALL 展示所有可购买的坦克皮肤,按分类展示:基础皮肤(金币购买)、高级皮肤(钻石购买)、限定皮肤(活动/赛季获取)。
2. WHEN 玩家购买皮肤 THEN 系统 SHALL 扣除对应货币并解锁皮肤,皮肤永久拥有。
3. WHEN 玩家装备皮肤 THEN 系统 SHALL 在所有游戏模式中展示该皮肤外观。
4. WHEN 皮肤为限定类型(赛季限定/节日限定) THEN 系统 SHALL 在限定期结束后下架,已购买的玩家永久保留。
5. WHEN 玩家查看皮肤详情 THEN 系统 SHALL 展示皮肤预览、价格、获取途径等信息。
---
### 需求 8:体力系统
**用户故事:** 作为游戏运营方,我希望通过体力系统控制玩家游戏节奏,以便创造付费点并提升玩家留存。
#### 验收标准
1. WHEN 玩家开始一局经典模式或无尽模式 THEN 系统 SHALL 消耗1点体力。
2. WHEN 玩家体力不足 THEN 系统 SHALL 阻止进入关卡,并展示体力恢复选项(等待自然恢复/观看广告/钻石购买)。
3. WHEN 体力未满 THEN 系统 SHALL 每6分钟自动恢复1点体力,体力上限为20点。
4. WHEN 玩家使用钻石购买体力 THEN 系统 SHALL 立即恢复满体力(每日钻石购买上限3次,价格递增:5/10/20钻石)。
5. WHEN 玩家进行双人对战或3v3对战 THEN 系统 SHALL 不消耗体力(PvP模式免体力)。
---
### 需求 9:付费节奏与引导
**用户故事:** 作为游戏运营方,我希望根据玩家生命周期阶段推送合适的付费引导,以便提高付费转化率。
#### 验收标准
##### 9.1 新手期(1-3天)
1. WHEN 新用户首次进入游戏 THEN 系统 SHALL 在24小时内展示1元新手礼包弹窗(最多展示3次)。
2. WHEN 新用户首次遇到插屏广告 THEN 系统 SHALL 展示"去广告特权"推荐入口。
##### 9.2 成长期(4-14天)
3. WHEN 玩家累计登录达4天 THEN 系统 SHALL 在商店页面高亮展示月卡推荐。
4. WHEN 玩家累计登录达7天 THEN 系统 SHALL 推送钻石充值促销活动(限时加赠20%)。
##### 9.3 成熟期(15天+
5. WHEN 玩家累计登录达15天且赛季进行中 THEN 系统 SHALL 推送战斗通行证购买引导。
6. WHEN 新赛季开始 THEN 系统 SHALL 向成熟期玩家推送限量皮肤预告。
---
### 需求 10:未成年人保护与合规
**用户故事:** 作为游戏运营方,我希望游戏符合未成年人保护法规和平台合规要求,以便合法合规运营。
#### 验收标准
##### 10.1 未成年人保护
1. WHEN 系统识别到未成年用户 THEN 系统 SHALL 在22:00-8:00期间禁止登录游戏。
2. WHEN 未成年用户进行消费 THEN 系统 SHALL 限制月消费上限为¥400,单次消费超过¥50时弹出确认提示。
3. WHEN 未成年用户观看广告 THEN 系统 SHALL 限制每日广告展示不超过5次。
##### 10.2 概率公示
4. WHEN 游戏中存在随机奖励机制(宝箱、抽奖等) THEN 系统 SHALL 在对应界面明确公示所有物品的掉落概率。
##### 10.3 退款与数据隐私
5. WHEN 玩家申请退款 THEN 系统 SHALL 符合微信小游戏退款规范,支持合理退款请求。
6. WHEN 游戏收集用户数据 THEN 系统 SHALL 明确告知数据收集范围,并提供关闭选项。
##### 10.4 反作弊
7. WHEN 系统检测到同一广告源IP短时间内大量请求 THEN 系统 SHALL 触发广告反刷机制,限制该IP的广告展示。
8. WHEN 系统检测到异常大额充值行为 THEN 系统 SHALL 触发人工审核流程。
9. WHEN 系统检测到对局数据异常(如不可能的击杀数/速度) THEN 系统 SHALL 标记该账号并进行封禁处理。
---
## 边界情况与技术约束
### 边界情况
1. **广告加载失败**:激励视频加载失败时,应提供备选方案(提示稍后重试),不阻塞玩家正常流程。
2. **支付异常**:支付过程中网络中断时,需在网络恢复后自动查询订单状态并补发商品。
3. **跨设备同步**:玩家换设备登录时,所有购买记录和货币余额需从云端恢复。
4. **赛季切换**:赛季结束时,未领取的通行证奖励需给予一定的缓冲期(如3天)供玩家领取。
5. **月卡续费失败**:自动续费失败时,需通知玩家并保留3天的权益缓冲期。
6. **货币溢出**:金币/钻石数量需设置合理上限,防止数值溢出。
### 技术约束
1. 微信小游戏内购需通过微信支付接口(`wx.requestMidasPayment`)完成,需接入米大师虚拟支付。
2. 广告SDK需使用微信小游戏广告组件(`wx.createRewardedVideoAd``wx.createInterstitialAd`)。
3. 所有货币和商品数据需服务端校验,防止客户端篡改。
4. 通行证和赛季数据需服务端管理赛季时间和奖励配置。
5. 未成年人识别依赖微信平台提供的用户年龄信息接口。
### 成功标准
1. 激励视频广告转化率 ≥ 8%(复活场景)。
2. 付费率 ≥ 3%ARPPU ≥ ¥50。
3. 高级通行证购买率 ≥ 5%。
4. 广告展示不影响核心游戏体验(玩家满意度调查 ≥ 4/5)。
5. 所有内购流程零丢单率(支付成功但未发货的情况为0)。
+234
View File
@@ -0,0 +1,234 @@
# 实施计划:坦克大作战商业化
> 基于 [requirements.md](./requirements.md) 商业化需求文档,按版本阶段拆解为可执行的编码任务。
---
## V1.0 基础版 — 激励广告 + 插屏广告
- [ ] 1. 扩展 AdManager,实现场景化广告触发与频控机制
- [ ] 1.1 在 `js/managers/AdManager.js` 中新增广告场景枚举(REVIVE、DOUBLE_REWARD、CHEST_SPEED、STAMINA、FREE_GIFT),为每个场景维护独立的冷却计时器(15分钟内不重复展示同一场景广告)
- 新增 `_sceneCooldowns` Map 和 `canShowScene(sceneType)` 方法
- 新增 `showRewardedVideoForScene(sceneType, callback)` 方法,内部调用 `canShowScene` 检查后再调用 `showRewardedVideo`
- 新增每日广告次数计数(体力恢复场景每日上限5次),使用 StorageManager 持久化当日计数
- _需求:1.6 广告体验优化(验收标准15、16、17)、1.4 体力恢复广告(验收标准12)_
- [ ] 1.2 在 `js/managers/AdManager.js` 中实现广告预加载机制
- 新增 `preloadRewardedVideo()` 方法,在关卡加载时提前调用
- 在 GameScene 和 TeamGameScene 的初始化阶段调用预加载
- _需求:1.6 广告体验优化(验收标准15)_
- [ ] 1.3 优化插屏广告频控逻辑
- 将现有的 `_gamesPlayed` 计数改为基于"距上次展示的局数"判断(每3局展示一次)
- 确保 `_adFreeEnabled` 标志正确跳过所有插屏广告
- 插屏广告加载失败时静默跳过,不阻塞流程
- _需求:2 插屏广告(验收标准1、2、3、4)_
- [ ] 2. 实现复活续关广告功能
- [ ] 2.1 在 `js/scenes/GameScene.js` 的玩家死亡流程中集成复活广告
- 在玩家生命数降为0时,弹出"观看广告复活"弹窗(而非直接进入失败结算)
- 新增 `_showReviveAdDialog()` 方法,展示选择界面(观看广告 / 放弃)
- 新增 `_reviveAdUsed` 标志,确保每关最多使用1次复活
- 观看完成后调用 `_revivePlayer()` 方法:保留火力等级,在出生点重生,恢复1条生命
- 选择放弃或广告不可用时,正常进入失败结算
- _需求:1.1 复活续关广告(验收标准1、2、3、4)_
- [ ] 2.2 在 `js/scenes/TeamGameScene.js` 中同步实现 PvP 模式的复活广告(如适用)
- 根据 PvP 模式规则决定是否支持复活广告(可配置开关)
- _需求:1.1 复活续关广告(验收标准1)_
- [ ] 3. 实现双倍结算广告功能
- [ ] 3.1 在 `js/scenes/ResultScene.js` 的结算界面中添加"双倍奖励"按钮
- 在结算界面新增"观看广告获得双倍奖励"按钮
- 调用 `AdManager.showRewardedVideoForScene('DOUBLE_REWARD', callback)`
- 观看完成后将金币和经验值翻倍显示并发放
- 广告加载失败时提示"广告暂时不可用",按正常倍率发放
- _需求:1.2 双倍结算广告(验收标准5、6、7)_
- [ ] 3.2 在 `js/scenes/TeamResultScene.js` 中同步实现 PvP 结算的双倍奖励广告
- _需求:1.2 双倍结算广告(验收标准5、6、7)_
- [ ] 4. 在结算流程中集成插屏广告触发
- 在 ResultScene 和 TeamResultScene 退出关卡时调用 `AdManager.showInterstitial()`
- 确保去广告特权用户不展示
- _需求:2 插屏广告(验收标准1、2、3)_
---
## V1.5 内购版 — 货币体系 + 支付 + 皮肤商店
- [ ] 5. 实现货币系统(CurrencyManager
- [ ] 5.1 新建 `js/managers/CurrencyManager.js`,实现金币和钻石的管理
- 实现 `getGold()` / `addGold(amount)` / `spendGold(amount)` 方法
- 实现 `getDiamonds()` / `addDiamonds(amount)` / `spendDiamonds(amount)` 方法
- 所有货币变动通过 EventBus 发送事件(`currency:gold:changed``currency:diamonds:changed`),供 UI 监听更新
- 使用 StorageManager 持久化货币数据,并纳入云同步数据
- 设置货币上限防止溢出(金币上限999999,钻石上限99999
- _需求:3.1 金币系统(验收标准1、2、3)、3.2 钻石系统(验收标准5、6、7)_
- [ ] 5.2 在 `js/base/GameGlobal.js` 中注册 CurrencyManager 实例
- 在全局初始化流程中创建并挂载 `GameGlobal.currencyManager`
- _需求:3.1、3.2_
- [ ] 5.3 在 ResultScene / TeamResultScene 的结算逻辑中集成金币发放
- 根据击杀数、通关时间等计算金币奖励并调用 `CurrencyManager.addGold()`
- _需求:3.1 金币系统(验收标准1)_
- [ ] 6. 实现体力系统(StaminaManager
- [ ] 6.1 新建 `js/managers/StaminaManager.js`,实现体力管理
- 实现 `getStamina()` / `consumeStamina()` / `recoverStamina(amount)` 方法
- 实现自然恢复逻辑:每6分钟恢复1点,上限20点,使用定时器 + 离线时间差计算
- 实现钻石购买体力:每日上限3次,价格递增(5/10/20钻石)
- 实现广告恢复体力:每次恢复5点,每日上限5次
- 通过 EventBus 发送 `stamina:changed` 事件
- 使用 StorageManager 持久化体力值和上次恢复时间戳
- _需求:8 体力系统(验收标准1、2、3、4、5)、1.4 体力恢复广告(验收标准10、11、12)_
- [ ] 6.2 在 GameScene 开始关卡前检查体力
- 进入经典模式/无尽模式前调用 `StaminaManager.consumeStamina()`
- 体力不足时弹出恢复选项弹窗(等待/广告/钻石)
- PvP 模式(TeamGameScene)不消耗体力
- _需求:8 体力系统(验收标准1、2、5)_
- [ ] 7. 实现支付模块(PaymentManager
- [ ] 7.1 新建 `js/managers/PaymentManager.js`,封装微信支付接口
- 封装 `wx.requestMidasPayment` 调用,实现 `purchase(productId, callback)` 方法
- 实现订单状态查询和补发逻辑(网络中断后自动重试)
- 实现商品配置表:去广告特权(¥30)、月卡(¥12)、钻石包(¥6/¥30/¥68)、新手礼包(¥1)、皮肤礼包(¥18-68)、高级通行证(¥18)
- 购买成功后通过 EventBus 发送 `purchase:completed` 事件
- 将购买记录同步至 StorageManager 和云端
- _需求:4.6 支付与安全(验收标准15、16、17)_
- [ ] 7.2 实现去广告特权购买逻辑
- 购买成功后调用 `AdManager.enableAdFree()`
- 保留激励视频入口,仅移除插屏广告
- _需求:4.1 去广告特权(验收标准1、2、3)_
- [ ] 7.3 实现钻石充值包购买逻辑
- 按规格发放钻石(60/360/880),首充双倍
- 使用 StorageManager 记录首充状态
- _需求:4.3 钻石充值包(验收标准8、9)_
- [ ] 7.4 实现新手礼包逻辑
- 新用户首次进入游戏后24小时内展示购买入口
- 倒计时结束后自动移除入口
- _需求:4.5 新手礼包(验收标准13、14)_
- [ ] 8. 实现皮肤系统与商店场景
- [ ] 8.1 新建 `js/data/SkinData.js`,定义皮肤配置数据
- 定义皮肤分类(基础/高级/限定)、价格(金币/钻石)、获取途径、皮肤资源映射
- _需求:7 皮肤商店系统(验收标准1、5)_
- [ ] 8.2 新建 `js/managers/SkinManager.js`,实现皮肤解锁与装备管理
- 实现 `getOwnedSkins()` / `unlockSkin(skinId)` / `equipSkin(skinId)` / `getCurrentSkin()` 方法
- 使用 StorageManager 持久化已解锁皮肤和当前装备
- 纳入云同步数据
- _需求:7 皮肤商店系统(验收标准2、3、4)_
- [ ] 8.3 新建 `js/scenes/ShopScene.js`,实现商店界面
- 展示皮肤列表(分类标签页:基础/高级/限定)
- 展示皮肤预览、价格、购买/装备按钮
- 集成 CurrencyManager 扣款和 SkinManager 解锁
- 展示去广告特权、钻石充值包等 IAP 商品入口
- _需求:7 皮肤商店系统(验收标准1、2、5)、4.1、4.3_
- [ ] 8.4 在 `js/scenes/MenuScene.js` 中添加商店入口按钮
- 点击后通过 SceneManager 跳转到 ShopScene
- _需求:7 皮肤商店系统_
- [ ] 8.5 在 Tank 渲染逻辑中集成皮肤系统
- 修改 `js/entities/Tank.js``PlayerTank.js`,根据 `SkinManager.getCurrentSkin()` 加载对应皮肤资源
- _需求:7 皮肤商店系统(验收标准3)_
---
## V2.0 赛季版 — 战斗通行证 + 任务体系
- [ ] 9. 实现战斗通行证系统(BattlePassManager
- [ ] 9.1 新建 `js/data/BattlePassData.js`,定义赛季配置
- 定义赛季时长(28天)、等级数(免费20级/高级40级)、每级奖励内容
- 定义每日任务池、每周任务池、赛季成就列表及对应经验值
- _需求:5.1 赛季基础设计(验收标准1、2、3)、5.2 任务体系(验收标准5、6、7)_
- [ ] 9.2 新建 `js/managers/BattlePassManager.js`,实现通行证核心逻辑
- 实现赛季状态管理:当前赛季ID、开始/结束时间、玩家等级、经验值
- 实现 `addExp(amount)` / `getLevel()` / `claimReward(level)` 方法
- 实现免费/高级通行证奖励轨道区分
- 实现任务生成与完成追踪(每日3+2任务、每周5+3任务、赛季10成就)
- 赛季结束时保留赛季币余额,提供3天奖励领取缓冲期
- 使用 StorageManager 持久化赛季进度
- _需求:5.1(验收标准1、2、3、4)、5.2(验收标准5、6、7、8)_
- [ ] 9.3 新建 `js/scenes/BattlePassScene.js`,实现通行证界面
- 展示等级进度条、免费/高级奖励轨道
- 展示每日任务、每周任务、赛季成就列表及完成状态
- 高级通行证购买入口(集成 PaymentManager
- 免费玩家达到10级时展示高级版奖励预览
- 赛季剩余不足3天时展示8折优惠
- _需求:5.1(验收标准2、3、4)、5.3 转化策略(验收标准9、10)_
- [ ] 9.4 在 MenuScene 中添加通行证入口,在 GameScene/TeamGameScene 结算时触发任务进度更新
- 对局结束后检查并更新任务完成状态(如"击杀N个敌人"、"通关N次"等)
- _需求:5.2 任务体系(验收标准8)_
- [ ] 10. 实现月卡系统
- [ ] 10.1 在 `js/managers/PaymentManager.js` 中新增月卡购买与状态管理
- 实现月卡有效期检查、每日登录领取100钻石、专属头像框解锁/过期
- 使用 StorageManager 记录月卡购买时间和每日领取状态
- _需求:4.2 月卡(验收标准4、5、6)_
- [ ] 10.2 在游戏启动流程中检查月卡状态并弹出每日领取弹窗
- _需求:4.2 月卡(验收标准5)_
---
## V2.5 社交版 — 分享裂变 + 付费引导 + 合规
- [ ] 11. 扩展 ShareManager,实现分享激励体系
- [ ] 11.1 在 `js/managers/ShareManager.js` 中新增分享奖励逻辑
- 每日首次分享发放50金币(通过 CurrencyManager
- 分享战绩概率发放稀有道具,每日上限3次
- 使用 StorageManager 记录每日分享次数和奖励领取状态
- _需求:6.1 分享激励(验收标准1、4)_
- [ ] 11.2 实现邀请新用户奖励机制
- 新用户完成新手引导后,向邀请者发放200金币(每日上限5人)
- 新用户获得双倍经验卡(3天有效期)
- 实现 IP + 设备指纹去重防作弊(服务端校验)
- _需求:6.1 分享激励(验收标准2、3)、6.2 防作弊机制(验收标准6、7)_
- [ ] 12. 实现付费节奏引导系统
- [ ] 12.1 新建 `js/managers/PromotionManager.js`,实现生命周期付费引导
- 根据玩家累计登录天数判断所处阶段(新手期/成长期/成熟期)
- 新手期(1-3天):展示新手礼包弹窗(最多3次)、去广告特权推荐
- 成长期(4-14天):高亮月卡推荐、钻石充值促销(限时加赠20%)
- 成熟期(15天+):推送通行证购买引导、限量皮肤预告
- 使用 StorageManager 记录弹窗展示次数和登录天数
- _需求:9.1(验收标准1、2)、9.2(验收标准3、4)、9.3(验收标准5、6)_
- [ ] 12.2 在 `game.js` 启动流程和 MenuScene 中集成 PromotionManager 的引导触发
- 登录时检查阶段并展示对应引导弹窗
- _需求:9 付费节奏与引导_
- [ ] 13. 实现未成年人保护与合规机制
- [ ] 13.1 新建 `js/managers/ComplianceManager.js`,实现合规检查
- 实现未成年人识别(调用微信平台用户年龄信息接口)
- 实现游戏时间限制:22:00-8:00禁止未成年人登录
- 实现消费限制:月消费上限¥400,单次>¥50弹出确认
- 实现广告展示限制:未成年人每日广告不超过5次
- _需求:10.1 未成年人保护(验收标准1、2、3)_
- [ ] 13.2 在宝箱/抽奖等随机奖励界面添加概率公示
- 在对应 UI 中明确展示所有物品的掉落概率
- _需求:10.2 概率公示(验收标准4)_
- [ ] 13.3 在服务端 `server/index.js` 中新增反作弊检测逻辑
- 广告反刷:同一IP短时间大量请求时限制广告展示
- 异常充值检测:大额充值触发人工审核标记
- 对局数据校验:异常击杀数/速度标记封禁
- _需求:10.4 反作弊(验收标准7、8、9)_
- [ ] 14. 国际化支持:为所有商业化 UI 文案添加 i18n 翻译
-`js/i18n/zh.js``js/i18n/en.js` 中新增商店、通行证、体力、货币、弹窗等所有商业化相关的翻译键值
- 确保所有新增场景和弹窗使用 `I18n.t()` 获取文案
- _需求:全部商业化需求的 UI 文案_
+277
View File
@@ -0,0 +1,277 @@
# 需求文档:坦克大作战(微信小游戏版)
## 引言
《坦克大作战》是一款面向微信小游戏平台的经典坦克对战休闲游戏,目标用户为25-40岁怀旧玩家及泛休闲用户。游戏核心卖点为"单手操作还原FC手感 + 微信好友排行榜/对战"。技术栈采用 Cocos Creator + 微信小游戏云托管。
游戏核心循环为单局3-5分钟的快节奏体验:玩家需保护基地不被敌方坦克摧毁,击毁所有刷新的敌方坦克,通过拾取道具升级火力和辅助通关,最终根据击杀数、通关时间、基地血量计算得分并上传排行榜。
---
## 需求
### 需求 1:游戏基础框架与场景管理
**用户故事:** 作为一名玩家,我希望游戏能够流畅加载并在不同场景间平滑切换,以便获得良好的游戏体验。
#### 验收标准
1. WHEN 玩家打开小游戏 THEN 系统 SHALL 展示加载界面并在3秒内完成资源加载,进入主菜单场景。
2. WHEN 资源加载完成 THEN 系统 SHALL 支持以下场景的管理与切换:主菜单、游戏关卡、结算界面、排行榜界面。
3. WHEN 玩家在任意场景中操作 THEN 系统 SHALL 保持稳定的60FPS帧率(目标值),避免明显卡顿。
4. WHEN 游戏运行时 THEN 系统 SHALL 使用对象池技术管理子弹、爆炸特效等高频创建/销毁的对象,避免内存抖动。
5. IF 设备性能较低 THEN 系统 SHALL 自动降级渲染效果(如减少粒子特效),保证基本流畅度。
---
### 需求 2:地图与地形系统
**用户故事:** 作为一名玩家,我希望游戏拥有经典的砖块、钢铁、河流、森林等地形元素,以便体验丰富的战术策略。
#### 验收标准
1. WHEN 关卡加载时 THEN 系统 SHALL 基于13×21的网格(13行×21列)生成俯视2D地图,横屏布局确保在手机上视野宽阔清晰。
2. WHEN 地图中存在**砖块**地形 THEN 系统 SHALL 允许子弹击碎砖块(Lv3子弹可一次击碎更大范围),砖块被击碎后变为可通行区域。
3. WHEN 地图中存在**钢铁**地形 THEN 系统 SHALL 阻挡普通子弹(Lv1/Lv2),仅允许Lv3(破钢)子弹摧毁钢铁墙。
4. WHEN 地图中存在**河流**地形 THEN 系统 SHALL 阻止坦克通行但允许子弹飞越河流。
5. WHEN 地图中存在**森林**地形 THEN 系统 SHALL 允许坦克和子弹通过,但森林覆盖在坦克上方(遮挡视觉)。
6. WHEN 地图加载时 THEN 系统 SHALL 在地图底部中央放置玩家基地(老鹰图标),基地周围默认有砖块围墙保护。
7. WHEN 关卡数据加载时 THEN 系统 SHALL 支持从预设的关卡配置数据中读取地图布局(支持未来扩展自定义关卡)。
---
### 需求 3:玩家坦克操控系统
**用户故事:** 作为一名玩家,我希望通过虚拟摇杆和发射按钮操控坦克,以便在手机上获得流畅的操作体验。
#### 验收标准
1. WHEN 游戏进入战斗场景 THEN 系统 SHALL 在屏幕左下角显示虚拟摇杆(控制上下左右四方向移动),右下角显示发射按钮。
2. WHEN 玩家拖动虚拟摇杆 THEN 系统 SHALL 控制坦克朝对应方向移动,坦克炮管朝向与移动方向一致。
3. WHEN 玩家点击发射按钮 THEN 系统 SHALL 从坦克炮管方向发射一颗子弹。
4. IF 玩家坦克火力等级为Lv1 THEN 系统 SHALL 限制同屏最多1颗玩家子弹(单发模式)。
5. IF 玩家坦克火力等级为Lv2 THEN 系统 SHALL 允许快速连射(同屏最多2颗子弹,射速提升)。
6. IF 玩家坦克火力等级为Lv3 THEN 系统 SHALL 允许连发(同屏最多2颗子弹)且子弹具备破钢能力。
7. WHEN 玩家坦克被敌方子弹击中或与敌方坦克碰撞 THEN 系统 SHALL 判定玩家坦克被摧毁,扣除一条生命,并在出生点重生(默认3条生命)。
8. WHEN 玩家生命数降为0 THEN 系统 SHALL 触发游戏失败流程(可选择广告复活或结算)。
---
### 需求 4:敌方坦克AI系统
**用户故事:** 作为一名玩家,我希望敌方坦克具有不同类型和智能行为,以便获得有挑战性的游戏体验。
#### 验收标准
1. WHEN 关卡开始时 THEN 系统 SHALL 从地图顶部的预设出生点依次刷新敌方坦克,每关总计约20辆。
2. WHEN 敌方坦克刷新时 THEN 系统 SHALL 根据关卡配置生成以下4种类型之一:普通坦克(标准速度/血量)、快速坦克(高速低血量)、重甲坦克(低速需2-4次命中)、精英坦克/BOSS(高血量+智能AI)。
3. WHEN 敌方坦克处于巡逻状态 THEN 系统 SHALL 控制其沿随机方向移动并定时发射子弹。
4. WHEN 敌方坦克发现通往基地的路径 THEN 系统 SHALL 切换至追击状态,优先向基地方向移动。
5. IF 关卡编号 ≥ 10 THEN 敌方坦克AI SHALL 具备"绕路偷家"能力,尝试绕过障碍物包抄基地。
6. IF 关卡编号 ≥ 15 THEN 敌方坦克AI SHALL 具备"集火基地"能力,多辆坦克协同攻击基地方向。
7. WHEN 敌方坦克AI进行寻路时 THEN 系统 SHALL 采用简单的A*或方向权重算法,确保在微信小游戏环境下性能可控。
8. WHEN 玩家子弹与敌方子弹碰撞 THEN 系统 SHALL 使双方子弹同时抵消消失。
---
### 需求 5:道具系统
**用户故事:** 作为一名玩家,我希望在战斗中拾取各种道具来增强自身能力,以便更好地通关。
#### 验收标准
1. WHEN 玩家击毁特定标记的敌方坦克 THEN 系统 SHALL 在地图随机位置生成一个道具,道具存在时间为15秒(闪烁提示后消失)。
2. WHEN 玩家坦克触碰**星星**道具 THEN 系统 SHALL 提升玩家火力等级(Lv1→Lv2→Lv3,已满级则无额外效果)。
3. WHEN 玩家坦克触碰**时钟**道具 THEN 系统 SHALL 冻结全屏所有敌方坦克10秒(敌方坦克停止移动和射击)。
4. WHEN 玩家坦克触碰**炸弹**道具 THEN 系统 SHALL 立即摧毁当前屏幕上所有可见的敌方坦克。
5. WHEN 玩家坦克触碰**钢盔**道具 THEN 系统 SHALL 使玩家坦克进入无敌状态(持续15秒),期间闪烁护盾特效。
6. WHEN 玩家坦克触碰**铲子**道具 THEN 系统 SHALL 将基地周围的砖块围墙临时替换为钢铁墙(持续20秒后恢复)。
7. WHEN 玩家坦克触碰**坦克(+1**道具 THEN 系统 SHALL 增加玩家一条生命。
8. WHEN 道具掉落时 THEN 系统 SHALL 根据当前关卡编号调整掉落概率(关卡越高,星星掉落率越低,增加挑战性)。
---
### 需求 6:基地防守与胜负判定
**用户故事:** 作为一名玩家,我希望有明确的胜负条件,以便清楚了解每局游戏的目标。
#### 验收标准
1. WHEN 基地被任意子弹(含己方误伤)击中 THEN 系统 SHALL 判定基地被摧毁,立即触发游戏失败(Game Over)。
2. WHEN 玩家击毁本关所有敌方坦克(约20辆)且基地未被摧毁 THEN 系统 SHALL 判定本关胜利,进入结算界面。
3. WHEN 玩家生命数降为0且无复活机会 THEN 系统 SHALL 判定游戏失败,进入结算界面。
4. WHEN 进入结算界面 THEN 系统 SHALL 展示本局得分(根据击杀数、通关时间、基地存活状态综合计算)、击杀各类型坦克的统计数据。
---
### 需求 7:关卡系统与难度曲线
**用户故事:** 作为一名玩家,我希望游戏关卡由易到难逐步递进,以便获得持续的挑战感和成就感。
#### 验收标准
1. WHEN 玩家首次进入游戏 THEN 系统 SHALL 从第1关开始,前3关作为教学关(地形开阔、敌人慢速、提示操作方法)。
2. WHEN 玩家到达第5关 THEN 系统 SHALL 引入河流地形,考验玩家走位能力。
3. WHEN 玩家到达第10关 THEN 系统 SHALL 引入重甲坦克,玩家必须升级至Lv2以上火力才能有效击破。
4. WHEN 玩家到达第20关 THEN 系统 SHALL 生成BOSS关(巨型坦克),需配合道具策略击杀。
5. WHEN 玩家通过最后一关 THEN 系统 SHALL 循环回到第1关但整体难度提升(敌人速度/数量增加),实现无限关卡循环。
6. WHEN 关卡编号增加时 THEN 系统 SHALL 逐步提升敌方AI智能程度(从直线冲锋→绕路偷家→集火基地)。
7. WHEN 关卡编号增加时 THEN 系统 SHALL 逐步降低星星道具掉落率,提升游戏挑战性。
---
### 需求 8:游戏模式
**用户故事:** 作为一名玩家,我希望有多种游戏模式可选,以便获得不同的游戏体验。
#### 验收标准
1. WHEN 玩家在主菜单选择"经典模式" THEN 系统 SHALL 进入无限关卡循环模式,玩家逐关挑战并冲击好友排行榜。
2. WHEN 玩家在主菜单选择"无尽模式" THEN 系统 SHALL 进入无限波次敌人模式,比拼最高击杀数(首次需观看广告解锁)。
3. WHEN 玩家在主菜单选择"双人对战" THEN 系统 SHALL 通过微信邀请好友进行1v1实时对战(阵地破坏模式),双方各拥有1个基地,率先摧毁对方基地的一方获胜。
4. IF 玩家选择双人对战模式 THEN 系统 SHALL 提供简单的房间匹配机制(创建房间/加入房间),通过微信社交关系链邀请好友。
5. WHEN 双人对战开始时 THEN 系统 SHALL 生成对称式对战地图,双方各拥有1个基地,分别位于地图两端。
6. WHEN 任一方基地被摧毁 THEN 系统 SHALL 判定该方失败,对战立即结束并进入结算界面。对战不设时间限制,唯一的胜利条件为摧毁对方基地。
7. WHEN 玩家在双人对战中被击毁 THEN 系统 SHALL 在己方基地附近自动重生(无生命数限制),重生间隔为3秒。
8. WHEN 双人对战结算时 THEN 系统 SHALL 展示双方基地HP、击杀数、死亡数、阵地伤害等数据。
9. IF 玩家在双人对战中断线 THEN 系统 SHALL 尝试自动重连(最多5次),超时未重连则判定对方获胜。
---
### 需求 9:微信社交与排行榜系统
**用户故事:** 作为一名玩家,我希望能看到微信好友的排名并与他们互动,以便增加游戏的社交乐趣和竞争动力。
#### 验收标准
1. WHEN 玩家进入排行榜界面 THEN 系统 SHALL 通过微信开放数据域展示好友排名(按最高通关关卡/最高得分排序)。
2. WHEN 玩家通关后 THEN 系统 SHALL 自动将得分上传至微信云数据库,更新排行榜数据。
3. WHEN 玩家通关后 THEN 系统 SHALL 提供"挑战书"功能,生成包含"我通关了第X关,你敢挑战吗?"文案的分享卡片(带小程序码),可分享到群/朋友圈。
4. WHEN 玩家卡关时 THEN 系统 SHALL 提供"助战"功能,分享给好友后好友点击可为玩家发送"炸弹"道具援助。
5. WHEN 玩家本局得分超过好友最高分 THEN 系统 SHALL 在结算界面高亮提示"超越了好友XXX"。
---
### 需求 10:商业化系统(广告与内购)
**用户故事:** 作为游戏运营方,我希望通过合理的广告和内购设计实现商业化,同时不影响玩家核心体验。
#### 验收标准
1. WHEN 玩家死亡且有剩余复活机会 THEN 系统 SHALL 弹出"观看广告复活"选项(激励视频),复活后保留当前火力等级。
2. WHEN 关卡结算时 THEN 系统 SHALL 提供"观看广告获得双倍金币/道具"选项(激励视频)。
3. WHEN 玩家进入皮肤商店 THEN 系统 SHALL 展示可通过观看广告解锁的特殊皮肤(如"黄金坦克")。
4. WHEN 每局游戏结束或玩家退出关卡时 THEN 系统 SHALL 展示插屏广告(频率控制:每3局最多1次)。
5. IF 玩家购买"永久去广告"内购项 THEN 系统 SHALL 永久移除所有插屏广告(激励视频保留,因为是玩家主动选择)。
6. IF 玩家购买"皮肤包"内购项 THEN 系统 SHALL 解锁对应的坦克皮肤(如"红白机配色"经典皮肤包)。
---
### 需求 11:数据持久化与存档系统
**用户故事:** 作为一名玩家,我希望游戏进度能够自动保存,以便下次打开时继续游戏。
#### 验收标准
1. WHEN 玩家通过任意关卡 THEN 系统 SHALL 使用 `wx.setStorageSync` 自动保存当前关卡进度、最高分、生命数等本地数据。
2. WHEN 玩家重新打开游戏 THEN 系统 SHALL 读取本地存档数据,允许玩家从上次进度继续游戏或重新开始。
3. WHEN 玩家得分更新时 THEN 系统 SHALL 将最新得分同步至微信云数据库(用于排行榜)。
4. IF 本地存档数据损坏或丢失 THEN 系统 SHALL 从云端恢复玩家的关键进度数据(最高关卡、最高分)。
5. WHEN 玩家获得新皮肤或内购项 THEN 系统 SHALL 将购买记录同步至云端,确保换设备后不丢失。
---
### 需求 12:UI界面与用户体验
**用户故事:** 作为一名玩家,我希望游戏界面简洁美观、操作直觉化,以便快速上手并沉浸在游戏中。
#### 验收标准
1. WHEN 游戏启动 THEN 系统 SHALL 展示主菜单界面,包含:经典模式、无尽模式、双人对战、排行榜、设置等入口。
2. WHEN 进入战斗场景 THEN 系统 SHALL 在屏幕顶部显示HUD信息:当前关卡、剩余敌人数量、玩家生命数、当前火力等级。
3. WHEN 玩家暂停游戏 THEN 系统 SHALL 弹出暂停菜单(继续游戏、重新开始、返回主菜单)。
4. WHEN 玩家首次进入游戏 THEN 系统 SHALL 提供简短的新手引导(指示虚拟摇杆和发射按钮的用法,约2-3步)。
5. WHEN 游戏中出现重要事件(如道具拾取、敌人全灭、BOSS出现) THEN 系统 SHALL 播放对应的音效和简短的视觉反馈。
6. WHEN 玩家在设置界面操作 THEN 系统 SHALL 提供音效开关、音乐开关、振动开关等选项。
---
### 需求 133v3 对战模式
**用户故事:** 作为一名玩家,我希望能与好友组队进行 3v3 破坏对方阵地的对战,以便获得更具团队协作感和竞技性的游戏体验。
#### 模式定位
玩法:3v3 破坏对方阵地。双方各拥有1个基地(阵地),不限时间,率先摧毁对方基地的一方获胜。
#### 核心流程
```mermaid
graph TD
A[玩家点击3v3入口] --> B{选择模式}
B -->|组队开黑| C[创建队伍]
B -->|单人| D[快速匹配]
C --> E[邀请好友入队]
E --> F[队伍满3人/点击匹配]
F --> G[系统匹配对手]
D --> H[加入匹配池]
G --> I[6人满房]
H --> I
I --> J[进入加载页]
J --> K[开始对战]
```
#### 验收标准
##### 好友开黑 — 队伍系统
1. WHEN 玩家在 3v3 入口选择"组队开黑" THEN 系统 SHALL 创建一个队伍房间,创建者自动成为**队长**。
2. WHEN 队长操作队伍时 THEN 系统 SHALL 赋予队长以下权限:邀请好友、踢出队员、开始匹配、解散队伍;队长头像带"队长"标识,操作按钮高亮。
3. WHEN 队员加入队伍后 THEN 系统 SHALL 赋予队员以下权限:准备/取消准备、退出队伍;队员界面显示"准备状态",无法操作匹配按钮。
4. WHEN 队长点击"邀请好友" THEN 系统 SHALL 调用微信好友列表,生成邀请卡片,卡片内容为"坦克3v3,速来开黑!"。
5. WHEN 好友点击邀请卡片 THEN 系统 SHALL 检测卡片中的 `teamId` 参数,将好友加入对应队伍房间(MGOBE Room)。
6. IF 队伍人数已满3人 THEN 系统 SHALL 拒绝新成员加入并提示"队伍已满"。
##### 匹配方案
7. WHEN 玩家单独点击"快速开始" THEN 系统 SHALL 将玩家加入单人匹配池,按段位/胜率进行匹配,补位至 3v3。
8. WHEN 队长点击"开始匹配" THEN 系统 SHALL 将队伍整体加入匹配池,寻找实力相近的对手队伍或散人进行组合。
9. IF 匹配超时(超过60秒未凑满6人) THEN 系统 SHALL 自动填充 AI 机器人补位,确保对局可以正常开始。
10. WHEN 6人满房(含 AI 填充) THEN 系统 SHALL 进入加载页,所有玩家同步加载对战地图资源。
##### 对战规则
11. WHEN 3v3 对战开始时 THEN 系统 SHALL 生成对称式对战地图,双方各拥有1个基地(阵地),分别位于地图两端,地图中央为争夺区域。
12. WHEN 任一方基地被摧毁 THEN 系统 SHALL 判定该方失败,对战立即结束并进入结算界面。对战不设时间限制,唯一的胜利条件为摧毁对方基地。
13. WHEN 玩家在 3v3 对战中被击毁 THEN 系统 SHALL 在己方基地附近自动重生(无生命数限制),重生间隔为3秒。所有坦克的出生点均位于所在基地附近区域。
14. WHEN 3v3 对战结算时 THEN 系统 SHALL 展示双方各玩家的击杀数、死亡数、助攻数、对阵地伤害等数据,并根据表现计算段位积分变化。
##### 网络与同步
15. WHEN 3v3 对战进行中 THEN 系统 SHALL 采用帧同步或状态同步方案(基于微信小游戏 MGOBE 或同等方案),确保6人对战的网络延迟可控。
16. IF 玩家在对战中断线 THEN 系统 SHALL 保留其位置60秒,期间允许重连恢复对战;超时未重连则由 AI 接管该玩家坦克。
---
## 边界情况与技术约束
### 边界情况
1. **网络断开**:排行榜上传失败时,系统应缓存数据并在网络恢复后自动重试。
2. **小游戏被后台切走**:系统应自动暂停游戏,返回前台后恢复。
3. **广告加载失败**:激励视频加载失败时,应提供备选方案(如直接给予较少奖励或提示稍后重试)。
4. **同屏大量对象**:当同屏坦克+子弹数量过多时,应通过对象池和渲染优化保证性能。
5. **玩家误伤基地**:玩家自己的子弹也可以摧毁基地围墙和基地本身,需保留此经典机制。
### 技术约束
1. 微信小游戏包体大小限制(首包≤4MB,分包≤20MB),需合理规划资源加载策略。
2. 微信小游戏不支持DOM API,所有UI需通过Canvas或Cocos Creator UI系统实现。
3. 双人对战需要实时通信,建议使用微信小游戏帧同步或状态同步方案。
4. 开放数据域(排行榜)与主域隔离,需通过SharedCanvas方案展示好友排名。
### 成功标准
1. 游戏首包加载时间 ≤ 3秒(4G网络环境)。
2. 战斗场景稳定帧率 ≥ 55FPS(中端机型)。
3. 单局游戏时长控制在3-5分钟。
4. 新手玩家3分钟内理解核心操作。
+151
View File
@@ -0,0 +1,151 @@
# 实施计划:坦克大作战(微信小游戏版)
> **技术栈**:纯 JavaScript + 原生 Canvas API(微信小游戏环境),不依赖 Cocos Creator 等重型引擎。
> **参考需求文档**`.codebuddy/plan/tankwar/requirements.md`
---
- [x] 1. 搭建微信小游戏项目基础框架与游戏主循环
- 创建微信小游戏项目结构(`game.js``game.json``project.config.json` 等)
- 实现 Canvas 初始化、屏幕适配(获取设备宽高,计算游戏区域缩放比例)
- 实现游戏主循环(`requestAnimationFrame`),包含 `update(dt)``render(ctx)` 两阶段
- 实现场景管理器(SceneManager),支持主菜单、游戏关卡、结算界面等场景的注册与切换
- 实现对象池(ObjectPool)工具类,用于子弹、爆炸特效等高频对象的复用
- 实现资源管理器(ResourceManager),使用 `wx.createImage` 预加载图片资源,支持加载进度回调
- _需求:1.1、1.2、1.3、1.4_
- [x] 2. 实现地图系统与地形渲染
- 定义地图数据结构(13×21 网格,每格用数字编码表示地形类型:空地/砖块/钢铁/河流/森林/基地)
- 编写至少 5 个预设关卡的地图配置数据(JSON 格式),包含教学关、河流关、重甲关等
- 实现 MapManager 类,负责从关卡配置加载地图、渲染地形 Tile、管理地形状态(砖块可被摧毁)
- 实现各地形类型的碰撞属性:砖块(可破坏/阻挡)、钢铁(Lv3可破/阻挡)、河流(阻挡坦克/子弹穿越)、森林(遮挡层/可通行)
- 实现基地区域渲染(老鹰图标 + 砖块围墙),基地被击中即摧毁的判定逻辑
- _需求:2.1、2.2、2.3、2.4、2.5、2.6、2.7、6.1_
- [x] 3. 实现玩家坦克与触控操作系统
- 实现 Tank 基类(位置、方向、速度、血量、渲染、碰撞盒等通用属性和方法)
- 实现 PlayerTank 子类,包含火力等级(Lv1-Lv3)、生命数、无敌状态、重生逻辑
- 实现虚拟摇杆组件(Joystick):监听 `touchstart/touchmove/touchend` 事件,左下角渲染摇杆UI,输出四方向(上/下/左/右)
- 实现发射按钮组件(FireButton):右下角渲染按钮UI,点击触发发射,根据火力等级限制同屏子弹数(Lv1=1颗,Lv2/Lv3=2颗)
- 实现坦克与地形的碰撞检测(矩形碰撞),阻止坦克进入不可通行区域
- _需求:3.1、3.2、3.3、3.4、3.5、3.6、3.7_
- [x] 4. 实现子弹系统与碰撞检测引擎
- 实现 Bullet 类(位置、方向、速度、所属阵营、是否破钢),从对象池获取/回收
- 实现碰撞检测管理器(CollisionManager),每帧检测:子弹↔地形、子弹↔坦克、子弹↔子弹、子弹↔基地、坦克↔坦克
- 实现子弹击中砖块的破坏逻辑(普通子弹破坏1格砖块,Lv3子弹破坏更大范围)
- 实现子弹击中钢铁的逻辑(Lv1/Lv2反弹/消失,Lv3摧毁钢铁)
- 实现敌我子弹对撞抵消逻辑
- 实现爆炸特效(帧动画),使用对象池管理
- _需求:2.2、2.3、3.6、4.8、6.1_
- [x] 5. 实现敌方坦克AI与刷新系统
- 实现 EnemyTank 子类,支持4种类型配置:普通(标准属性)、快速(高速/1HP)、重甲(低速/2-4HP)、精英BOSS(高HP/智能AI
- 实现敌方坦克刷新管理器(SpawnManager):从地图顶部3个出生点轮流刷新,控制同屏最大数量和刷新间隔,每关总计约20辆
- 实现基础AI状态机:巡逻状态(随机方向移动+定时射击)→ 追击状态(朝基地方向移动)
- 实现进阶AI行为:A*简化寻路(方向权重算法),支持绕路偷家(关卡≥10)和集火基地(关卡≥15)
- 实现敌方坦克被击毁的判定、计数和特效
- _需求:4.1、4.2、4.3、4.4、4.5、4.6、4.7_
- [x] 6. 实现道具系统
- 实现 PowerUp 类(类型、位置、存在时间、闪烁动画),支持6种道具:星星、时钟、炸弹、钢盔、铲子、坦克+1
- 实现道具生成逻辑:击毁特定标记敌方坦克后在随机位置生成道具,15秒后消失
- 实现各道具拾取效果:星星(升级火力)、时钟(冻结敌人10秒)、炸弹(清屏)、钢盔(无敌15秒+护盾特效)、铲子(基地围墙变钢铁20秒)、坦克+1(加命)
- 实现道具掉落概率配置表,根据关卡编号动态调整(高关卡降低星星概率)
- _需求:5.1、5.2、5.3、5.4、5.5、5.6、5.7、5.8_
- [x] 7. 实现关卡流程、胜负判定与结算系统
- 实现 LevelManager 类:管理关卡加载、敌人波次、胜负条件检测、关卡切换
- 实现胜利判定(所有敌人被消灭+基地存活)和失败判定(基地被毁或生命归零)
- 实现关卡结算界面:展示击杀统计(各类型坦克数量)、得分计算(击杀数×系数 + 时间奖励 + 基地存活奖励)
- 实现关卡难度曲线配置:前3关教学→第5关河流→第10关重甲→第20关BOSS,通关后循环并提升难度
- 实现玩家死亡→复活/Game Over流程(含广告复活入口预留)
- _需求:6.1、6.2、6.3、6.4、7.1、7.2、7.3、7.4、7.5、7.6、7.7、3.8_
- [x] 8. 实现UI系统(主菜单、HUD、暂停、新手引导)
- 实现主菜单场景:游戏标题、经典模式/无尽模式/双人对战/排行榜/设置按钮,纯Canvas绘制
- 实现战斗HUD:顶部显示当前关卡、剩余敌人数(坦克小图标)、玩家生命数、火力等级指示
- 实现暂停功能:暂停按钮 + 暂停菜单弹窗(继续/重新开始/返回主菜单)
- 实现新手引导:首次进入游戏时展示2-3步操作提示(摇杆移动→射击按钮→保护基地)
- 实现设置界面:音效开关、音乐开关、振动开关,使用 `wx.setStorageSync` 持久化设置
- 实现音效系统:使用 `wx.createInnerAudioContext` 管理射击、爆炸、道具拾取、胜利/失败等音效
- _需求:12.1、12.2、12.3、12.4、12.5、12.6、1.1_
- [x] 9. 实现数据持久化与微信云排行榜
- 实现 StorageManager 类:封装 `wx.setStorageSync/getStorageSync`,管理本地存档(当前关卡、最高分、生命数、皮肤、设置项)
- 实现通关自动存档和启动时读档恢复逻辑
- 实现微信云开发接入:云数据库初始化、得分上传云函数、排行榜数据查询
- 实现开放数据域(子域)排行榜:通过 SharedCanvas 展示微信好友排名(按最高关卡/最高得分排序)
- 实现分享功能:通关后生成"挑战书"分享卡片(`wx.shareAppMessage`),支持带参数跳转
- 实现网络异常处理:上传失败时缓存数据,网络恢复后自动重试
- _需求:9.1、9.2、9.3、9.4、9.5、11.1、11.2、11.3、11.4_
- [x] 10. 实现商业化系统(广告、内购)与游戏模式扩展
- 实现广告管理器(AdManager):封装激励视频(`wx.createRewardedVideoAd`)和插屏广告(`wx.createInterstitialAd`)的创建、加载、展示、失败回调
- 实现广告触发点:死亡复活(激励视频)、结算双倍奖励(激励视频)、局间插屏(每3局最多1次频控)
- 实现广告加载失败的降级方案(提示稍后重试或给予少量奖励)
- 实现内购接口预留:永久去广告、皮肤包购买(`wx.requestMidasPayment` 或虚拟支付)
- 实现无尽模式:无限波次敌人刷新,记录最高击杀数,首次需观看广告解锁
- 实现双人对战模式框架:房间创建/加入UI、微信好友邀请、基于帧同步的实时对战基础通信(可作为后续迭代重点)
- 实现小游戏生命周期处理:`wx.onShow/onHide` 自动暂停/恢复,后台切换保护
- _需求:8.1、8.2、8.3、8.4、10.1、10.2、10.3、10.4、10.5、10.6、11.5_
- [ ] 11. 移除时间限制并修正全局常量与服务端胜负逻辑
-`GameGlobal.js` 中移除 `TEAM_ROUND_TIME` 常量(或将其设为 0 / Infinity 表示不限时),确保不再作为对战时间限制使用
- 修改 `server/index.js``startTeamGame()` 函数:移除 `gameTimer`setTimeout 超时结束对战的逻辑),对战不再因时间到期而结束
- 修改 `server/index.js``endTeamGame()` 函数:移除 `timeout` 分支的基地血量比较逻辑,唯一的结束原因为 `base_destroyed`(某方基地被摧毁)
- 修改 `server/index.js``startTeamGame()` 发送的 `gameData`:移除 `roundTime` 字段,或将其设为 0 表示不限时
- 确认 `TeamRoom` 类中 `this.roundTime = 300` 不再影响游戏逻辑,可移除或保留为无效值
- _需求:13.12_
- [ ] 12. 修改客户端 TeamGameScene 移除倒计时并适配纯基地摧毁胜负
- 修改 `TeamGameScene.js``enter()` 方法:移除 `_roundTimer` 的初始化和使用
- 修改 `TeamGameScene.js``update()` 方法:移除 `_roundTimer -= dt` 倒计时逻辑及 `_roundTimer <= 0` 的超时判断
- 修改 `TeamGameScene.js` 中 HUD 渲染:移除顶部倒计时显示,改为显示对战已进行时间(正计时,仅作参考信息)
- 确保 `_handleTeamGameOver` 回调中正确处理 `reason === 'base_destroyed'` 的唯一胜负场景,移除 `timeout` / `draw` 相关的结果处理
- 修改 `TeamRoomScene.js``_startTeamGame()` 传参:不再传递 `roundTime`
- _需求:13.12_
- [ ] 13. 修正 3v3 对战地图为双方各 1 个基地的对称布局
- 检查 `LevelData.js``TEAM_MAPS` 数据:确保每张地图双方各只有 1 个基地(`TERRAIN.BASE`),分别位于地图左右两端
- 确保基地周围有砖块围墙保护(`TERRAIN.BASE_WALL`),地图中央为争夺区域
- 确保 `teamABase``teamBBase` 坐标正确指向各自唯一基地位置
- 验证 `MapManager` 对 3v3 地图中双基地的渲染和碰撞检测逻辑正确(两个基地分别可被对方子弹击中扣血)
- _需求:13.11_
- [ ] 14. 完善服务端 3v3 房间管理与匹配系统
- 检查并修复 `handleCreateTeam``handleJoinTeam``handleLeaveTeam``handleTeamReady``handleTeamKick``handleTeamDisband` 的边界情况处理
- 检查并修复 `handleMatchStart``handleMatchCancel``handleSoloMatch` 的匹配逻辑,确保队伍匹配和单人匹配正确配对
- 确保 `tryMatchTeams()` 中两队配对和散人组队逻辑正确,AI 填充在超时后正常触发
- 确保 `handleBaseHit` 中基地扣血和 `base_destroyed` 判定逻辑正确,且是唯一的游戏结束触发点
- 验证断线重连流程:`handleTeamPlayerDisconnect` → 60秒超时 → `BOT_TAKEOVER`,以及 `handleReconnect` 恢复逻辑
- _需求:13.1、13.2、13.3、13.5、13.6、13.7、13.8、13.9、13.10、13.15、13.16_
- [ ] 15. 完善客户端 TeamRoomScene 队伍房间交互
- 检查并修复 `TeamRoomScene.js` 中组队开黑流程:创建队伍 → 邀请好友 → 队员准备 → 队长开始匹配
- 检查并修复快速匹配(单人匹配)流程:点击快速匹配 → 进入匹配池 → 匹配成功/超时AI填充
- 确保队长权限按钮(邀请、踢人、开始匹配、解散)和队员按钮(准备/取消、退出)的交互逻辑正确
- 确保微信邀请卡片生成(`wx.shareAppMessage`)携带 `teamId` 参数,好友点击后能正确加入队伍
- 确保匹配状态UI(匹配中倒计时、取消匹配按钮)正确显示和响应
- 确保网络事件监听(`TEAM_STATE``TEAM_GAME_START``ROOM_ERROR``TEAM_DISBAND`)正确处理
- _需求:13.1、13.2、13.3、13.4、13.5、13.6、13.7、13.8、13.9、13.10_
- [ ] 16. 完善 TeamGameScene 对战核心逻辑与 HUD
- 确保 6 辆坦克(3v3)的创建、渲染、碰撞检测正确运行,己方子弹不伤害己方坦克
- 确保网络同步:本地玩家输入发送、远程玩家状态接收与插值平滑、子弹同步
- 确保基地血量系统正确:子弹击中基地 → 发送 `BASE_HIT` → 服务端扣血广播 → 客户端更新血量条
- 确保无限重生机制:被击毁后 3 秒在己方出生点重生,重生后 3 秒无敌
- 实现 HUD:双方基地血量条(顶部左右对称)、对战已进行时间(正计时)、本方队伍击杀/死亡统计
- 实现队友/敌方坦克颜色区分:己方蓝色系、敌方红色系、本地玩家金色高亮
- 实现 AI 机器人(`BotTank`)在对战中的行为:匹配填充的 AI 和断线接管的 AI 正常移动和射击
- _需求:13.11、13.12、13.13、13.14、13.15_
- [ ] 17. 实现 TeamResultScene 结算与主菜单入口集成
- 完善 `TeamResultScene.js` 结算界面:展示胜负结果(仅"胜利/失败",无平局)、双方各玩家击杀数/死亡数/助攻数/对阵地伤害
- 移除结算界面中与时间相关的展示(如"超时平局"等),胜负原因统一为"基地被摧毁"
- 实现段位积分变化显示:胜方加分、败方扣分、MVP 额外加分
- 实现结算后操作按钮:「再来一局」(返回队伍房间)、「返回主菜单」
- 确保 `MenuScene.js` 中「3v3 对战」按钮正确跳转到 `TeamRoomScene`
- 确保 `NetworkManager.js` 中 3v3 专用方法(`createTeam``joinTeam``teamReady``startMatch``kickPlayer``disbandTeam`)正确发送消息
- 确保 `game.js``wx.onShow``teamId` 检测逻辑正确,从邀请卡片进入游戏后自动加入队伍
- _需求:13.4、13.5、13.14、13.16_
@@ -0,0 +1,260 @@
# 需求文档:UI文案国际化(i18n)
## 引言
《坦克探险》微信小游戏需要支持中英文双语UI。根据用户所在区域自动展示对应语言的文案,中文地区显示中文,其他地区显示英文。
---
## 技术方案
### 1. i18n 模块结构
`js/i18n/` 目录下创建以下文件:
- **`I18n.js`** — 核心管理器,负责语言检测和文案获取
- **`zh.js`** — 中文语言包
- **`en.js`** — 英文语言包
### 2. 语言检测
通过微信 `wx.getSystemInfoSync().language` 自动检测:
- `zh_CN``zh_TW``zh_HK` 等以 `zh` 开头 → 使用中文
- 其他 → 使用英文(默认 fallback)
### 3. 使用方式
各场景文件通过 `const { t } = require('../i18n/I18n');` 引入翻译函数:
- 简单文案:`t('menu.title')``'坦克探险'` / `'Tank Adventure'`
- 带参数模板:`t('pvp.hp', { count: 3 })``'生命 x3'` / `'HP x3'`
### 4. Key 命名规范
按场景分组,使用点号分隔:
- `menu.*` — 主菜单
- `room.*` — 双人对战房间
- `teamRoom.*` — 3v3团队房间
- `pvp.*` — 双人对战游戏
- `team.*` — 3v3团队游戏
- `pvpResult.*` — 双人对战结算
- `teamResult.*` — 3v3团队结算
- `game.*` — 经典模式
- `common.*` — 通用文案
---
## 需求
### 需求 1:创建 i18n 核心模块
**用户故事:** 作为开发者,我需要一个 i18n 模块来管理多语言文案,支持自动语言检测和带参数的文案模板。
#### 验收标准
1. 创建 `js/i18n/I18n.js`,提供 `t(key, params)` 函数
2. 创建 `js/i18n/zh.js`,包含所有中文文案
3. 创建 `js/i18n/en.js`,包含所有英文文案
4. 通过 `wx.getSystemInfoSync().language` 自动检测语言
5. 支持 `{variable}` 占位符插值
6. 缺失 key 时 fallback 到英文,仍缺失则返回 key 本身
---
### 需求 2:主菜单场景(MenuScenei18n 化
#### 验收标准
| Key | 中文 | 英文 |
|-----|------|------|
| `menu.title` | 坦克探险 | Tank Adventure |
| `menu.subtitle` | 经典坦克对战 | TANK WAR |
| `menu.classic` | 经典模式 | Classic |
| `menu.endless` | 无尽模式 | Endless |
| `menu.pvp` | 双人对战 | PVP |
| `menu.team3v3` | 3v3 对战 | 3v3 Battle |
| `menu.ranking` | 排行榜 | Ranking |
| `menu.settings` | 设置 | Settings |
---
### 需求 3:双人对战房间场景(RoomScenei18n 化
#### 验收标准
| Key | 中文 | 英文 |
|-----|------|------|
| `room.title` | 双人对战 | PVP Battle |
| `room.idleHint` | 创建房间或输入房间号加入 | Create a room or join with a code |
| `room.create` | 创建房间 | Create Room |
| `room.join` | 加入房间 | Join Room |
| `room.connecting` | 连接中{dots} | Connecting{dots} |
| `room.roomCode` | 房间号: | Room Code: |
| `room.waiting` | 等待对手加入{dots} | Waiting for opponent{dots} |
| `room.shareHint` | 将房间号分享给好友 | Share the room code with your friend |
| `room.inputCode` | 输入房间号: | Enter Room Code: |
| `room.opponentFound` | 对手已找到! | Opponent found! |
| `room.starting` | 即将开始... | Game starting... |
| `room.tapBack` | 点击任意位置返回 | Tap anywhere to go back |
| `common.back` | ← 返回 | ← Back |
| `common.joinBtn` | 加入 | Join |
| `common.cannotConnect` | 无法连接服务器 | Cannot connect to server |
| `common.connectFailed` | 连接失败 | Connection failed |
| `common.disconnected` | 与服务器断开连接 | Disconnected from server |
---
### 需求 43v3 团队房间场景(TeamRoomScenei18n 化
#### 验收标准
| Key | 中文 | 英文 |
|-----|------|------|
| `teamRoom.title` | 3v3 团队对战 | 3v3 Team Battle |
| `teamRoom.chooseMode` | 选择游戏方式 | Choose how to play |
| `teamRoom.createTeam` | 🎮 组队开黑 | 🎮 Create Team |
| `teamRoom.soloMatch` | ⚡ 快速匹配 | ⚡ Quick Match |
| `teamRoom.teamId` | 队伍:{id} | Team: {id} |
| `teamRoom.leader` | 队长 | Leader |
| `teamRoom.ready` | ✓ 已准备 | ✓ Ready |
| `teamRoom.notReady` | 未准备 | Not Ready |
| `teamRoom.emptySlot` | 空位 | Empty |
| `teamRoom.invite` | 📨 邀请好友 | 📨 Invite |
| `teamRoom.startMatch` | 🔍 开始匹配 | 🔍 Start Match |
| `teamRoom.disband` | 解散队伍 | Disband |
| `teamRoom.readyBtn` | ✓ 准备 | ✓ Ready |
| `teamRoom.cancelReady` | 取消准备 | Cancel Ready |
| `teamRoom.leaveTeam` | 退出队伍 | Leave Team |
| `teamRoom.matching` | 匹配中{dots} | Matching{dots} |
| `teamRoom.waitTime` | 已等待 {seconds} 秒 | Waited {seconds}s |
| `teamRoom.cancelMatch` | 取消匹配 | Cancel Match |
| `teamRoom.matchFound` | 对手已找到! | Match found! |
| `teamRoom.enterBattle` | 即将进入战斗... | Entering battle... |
| `teamRoom.tapBack` | 点击任意位置返回 | Tap anywhere to go back |
| `teamRoom.shareTitle` | 坦克3v3,速来开黑! | Tank 3v3, join the battle! |
| `common.kicked` | 你已被踢出队伍 | You have been kicked from the team |
---
### 需求 5:双人对战游戏场景(PvpGameScenei18n 化
#### 验收标准
| Key | 中文 | 英文 |
|-----|------|------|
| `pvp.playerLabel` | P{slot} (我方) | P{slot} (You) |
| `pvp.hp` | 生命 x{count} | HP x{count} |
| `pvp.kills` | 击杀:{count} | Kills: {count} |
| `common.paused` | 暂停 | PAUSED |
| `common.tapContinue` | 点击继续 | Tap to continue |
| `pvp.youWin` | 你赢了! | YOU WIN! |
| `pvp.draw` | 平局 | DRAW |
| `pvp.youLose` | 你输了 | YOU LOSE |
---
### 需求 63v3 团队对战游戏场景(TeamGameScenei18n 化
#### 验收标准
| Key | 中文 | 英文 |
|-----|------|------|
| `team.teamA` | A队 | Team A |
| `team.teamB` | B队 | Team B |
| `team.myTeam` | 我方:{team}队 | You: {team} Team |
| `team.killDeath` | 杀:{kills} 亡:{deaths} | K:{kills} D:{deaths} |
| `team.respawn` | {seconds}秒后重生 | Respawning in {seconds}s |
| `team.victory` | 胜利! | VICTORY! |
| `team.defeat` | 失败 | DEFEAT |
| `team.baseHpSummary` | A队:{hpA} 生命 \| B队:{hpB} 生命 | Team A: {hpA} HP \| Team B: {hpB} HP |
| `team.disconnectTitle` | ⚠ 连接断开 | ⚠ Connection Lost |
| `team.reconnecting` | 重连中{dots} ({attempts}/{max}) | Reconnecting{dots} ({attempts}/{max}) |
| `team.reconnectHint` | 请稍候,您的坦克将由AI代管 | Please wait, your tank will be controlled by AI |
---
### 需求 7:双人对战结算场景(PvpResultScenei18n 化
#### 验收标准
| Key | 中文 | 英文 |
|-----|------|------|
| `pvpResult.title` | 对战结果 | MATCH RESULT |
| `pvpResult.victory` | 🏆 胜利! | 🏆 VICTORY! |
| `pvpResult.draw` | ⚔️ 平局 | ⚔️ DRAW |
| `pvpResult.defeat` | 💀 失败 | 💀 DEFEAT |
| `pvpResult.kills` | 击杀 | Kills |
| `pvpResult.lives` | 生命 | Lives |
| `pvpResult.timeRemaining` | 剩余时间:{time} | Time remaining: {time} |
| `pvpResult.rematch` | 再来一局 | Rematch |
| `pvpResult.backMenu` | 返回菜单 | Back to Menu |
---
### 需求 83v3 团队结算场景(TeamResultScenei18n 化
#### 验收标准
| Key | 中文 | 英文 |
|-----|------|------|
| `teamResult.title` | 3v3 对战结果 | 3v3 MATCH RESULT |
| `teamResult.victory` | 🏆 胜利! | 🏆 VICTORY! |
| `teamResult.defeat` | 💀 失败 | 💀 DEFEAT |
| `teamResult.teamAHp` | A队:{hp} 生命 | Team A: {hp} HP |
| `teamResult.teamBHp` | B队:{hp} 生命 | Team B: {hp} HP |
| `teamResult.baseDestroyed` | 基地被摧毁 | Base Destroyed |
| `teamResult.disconnectedReason` | 断线 | Disconnected |
| `teamResult.teamAHeader` | A队 | Team A |
| `teamResult.teamBHeader` | B队 | Team B |
| `teamResult.myTeamSuffix` | (我方) | (You) |
| `teamResult.player` | 玩家 | Player |
| `teamResult.k` | 杀 | K |
| `teamResult.d` | 亡 | D |
| `teamResult.a` | 助 | A |
| `teamResult.dmg` | 伤害 | DMG |
| `teamResult.bot` | 🤖 机器人 | 🤖 Bot |
| `teamResult.duration` | 对战时长:{time} | Match duration: {time} |
| `teamResult.mvp` | ⭐ MVP{name}{kills} 击杀) | ⭐ MVP: {name} ({kills} kills) |
| `teamResult.rankUp` | 📈 积分 +{points} | 📈 Rank +{points} |
| `teamResult.mvpBonus` | MVP加成 +5 | (MVP bonus +5) |
| `teamResult.rankDown` | 📉 积分 -{points} | 📉 Rank -{points} |
| `teamResult.rematch` | 再来一局 | Rematch |
| `teamResult.backMenu` | 返回菜单 | Back to Menu |
---
### 需求 9:经典模式游戏场景(GameScenei18n 化
#### 验收标准
| Key | 中文 | 英文 |
|-----|------|------|
| `game.level` | 第 {level} 关 | Level {level} |
| `game.hp` | 生命 x{count} | HP x{count} |
| `game.fireLevel` | LV{level} | LV{level} |
| `game.enemies` | 敌人: {count} | Enemies: {count} |
| `game.score` | {score}分 | {score}pts |
| `game.gameOver` | 游戏结束 | GAME OVER |
| `game.stageClear` | 关卡通过! | STAGE CLEAR! |
---
## 边界情况与技术约束
### 边界情况
1. **中文字体渲染**Canvas 中使用 `'Arial'` 字体渲染中文时,微信小游戏环境下系统会自动 fallback 到系统中文字体,无需额外处理。
2. **文案长度变化**:中英文文案长度不同,替换后需确认UI布局不会溢出或错位。
3. **`GameScene` 中的字符串比较**`text === '游戏结束'` 改为 `text === t('game.gameOver')`,确保逻辑不受语言影响。
4. **错误消息来源**:部分错误消息可能来自服务端(如 `data.message`),本次仅替换客户端硬编码的文案。
### 技术约束
1. 所有文案替换涉及 `js/scenes/` 目录下的场景文件和新建的 `js/i18n/` 模块。
2. 替换操作不应影响游戏逻辑,仅修改展示层的字符串。
3. 不需要在设置页面增加语言切换选项,完全依赖微信系统语言自动检测。
### 成功标准
1. 中文区域用户看到全中文UI,非中文区域用户看到全英文UI。
2. 替换后游戏功能正常,无因文案修改导致的逻辑错误。
3. 文案在各场景中布局合理,无溢出或错位现象。
@@ -0,0 +1,75 @@
# 实施计划:UI英文文案统一替换为中文
- [ ] 1. MenuScene 主菜单英文文案中文化
- 将副标题 `'TANK WAR'` 替换为 `'经典坦克对战'`
- 检查替换后文本居中是否正常,必要时调整绘制坐标
- _需求:1.1_
- [ ] 2. RoomScene 双人对战房间英文文案中文化
- 替换空闲状态提示 `'Create a room or join with a code'``'创建房间或输入房间号加入'`
- 替换连接状态 `'Connecting...'``'连接中...'`
- 替换等待状态 `'Room Code:'``'房间号:'``'Waiting for opponent...'``'等待对手加入...'``'Share the room code with your friend'``'将房间号分享给好友'`
- 替换输入状态 `'Enter Room Code:'``'输入房间号:'`
- 替换倒计时状态 `'Opponent found!'``'对手已找到!'``'Game starting...'``'即将开始...'`
- 替换错误状态 `'Tap anywhere to go back'``'点击任意位置返回'`
- 替换错误消息 `'Cannot connect to server'``'无法连接服务器'``'Connection failed'``'连接失败'``'Disconnected from server'``'与服务器断开连接'`
- _需求:2.1 ~ 2.12_
- [ ] 3. TeamRoomScene 3v3团队房间英文文案中文化
- 替换模式选择提示 `'Choose how to play'``'选择游戏方式'`
- 替换队伍ID显示 `'Team: xxx'``'队伍:xxx'`
- 替换错误消息 `'Cannot connect to server'``'无法连接服务器'``'You have been kicked from the team'``'你已被踢出队伍'``'Connection failed'``'连接失败'``'Disconnected from server'``'与服务器断开连接'`
- _需求:3.1 ~ 3.6_
- [ ] 4. PvpGameScene 双人对战游戏场景英文文案中文化
- 替换暂停覆盖层 `'PAUSED'``'暂停'``'Tap to continue'``'点击继续'`
- 替换游戏结束覆盖层 `'YOU WIN!'``'你赢了!'``'DRAW'``'平局'``'YOU LOSE'``'你输了'`
- 替换HUD玩家标识 `'P1 (You)'` / `'P2 (You)'``'P1 (我方)'` / `'P2 (我方)'`
- 替换HUD生命值 `'HP'``'生命'`
- 替换HUD击杀数 `'Kills:'``'击杀:'`
- _需求:4.1 ~ 4.8_
- [ ] 5. TeamGameScene 3v3团队对战游戏场景英文文案中文化
- 替换暂停覆盖层 `'PAUSED'``'暂停'``'Tap to continue'``'点击继续'`
- 替换游戏结束覆盖层 `'VICTORY!'``'胜利!'``'DEFEAT'``'失败'`
- 替换游戏结束基地HP显示 `'Team A: x HP | Team B: x HP'``'A队:x 生命 | B队:x 生命'`
- 替换HUD队伍标签 `'Team A'``'A队'``'Team B'``'B队'`
- 替换HUD玩家所属队伍 `'You: Team A'``'我方:A队'`
- 替换HUD队伍统计 `'K:x D:x'``'杀:x 亡:x'`
- 替换重生倒计时 `'Respawning in Xs'``'X秒后重生'`
- _需求:5.1 ~ 5.9_
- [ ] 6. PvpResultScene 双人对战结算场景英文文案中文化
- 替换结算标题 `'MATCH RESULT'``'对战结果'`
- 替换胜负结果 `'🏆 VICTORY!'``'🏆 胜利!'``'⚔️ DRAW'``'⚔️ 平局'``'💀 DEFEAT'``'💀 失败'`
- 替换玩家标识 `'P1 (You)'` / `'P2 (You)'``'P1 (我方)'` / `'P2 (我方)'`
- 替换统计表头 `'Kills'``'击杀'``'Lives'``'生命'`
- 替换剩余时间 `'Time remaining:'``'剩余时间:'`
- _需求:6.1 ~ 6.7_
- [ ] 7. TeamResultScene 3v3团队结算场景英文文案中文化
- 替换结算标题 `'3v3 MATCH RESULT'``'3v3 对战结果'`
- 替换胜负结果 `'🏆 VICTORY!'``'🏆 胜利!'``'💀 DEFEAT'``'💀 失败'`
- 替换基地HP `'Team A: x HP'``'A队:x 生命'``'Team B: x HP'``'B队:x 生命'`
- 替换胜负原因 `'Base Destroyed'``'基地被摧毁'``'Disconnected'``'断线'`
- 替换统计表头 `'Team A (You)'` / `'Team B (You)'``'A队 (我方)'` / `'B队 (我方)'`
- 替换列标题 `'Player'``'玩家'``'K'``'杀'``'D'``'亡'``'A'``'助'``'DMG'``'伤害'`
- 替换对战时长 `'Match duration:'``'对战时长:'`
- 替换MVP信息 `'⭐ MVP: xxx (x kills)'``'⭐ MVPxxxx 击杀)'`
- 替换段位积分 `'📈 Rank +x'``'📈 积分 +x'``'(MVP bonus +5)'``'MVP加成 +5'``'📉 Rank -x'``'📉 积分 -x'`
- 替换Bot名称 `'Bot'``'机器人'`
- _需求:7.1 ~ 7.11_
- [ ] 8. GameScene 经典模式游戏场景英文文案中文化
- 替换HUD生命值 `'HP'``'生命'`
- 替换游戏结束文案 `'GAME OVER'``'游戏结束'``'STAGE CLEAR'``'关卡通过'`(如存在)
- **注意**:同步修改代码中 `text === 'GAME OVER'` 等字符串比较逻辑,改为 `text === '游戏结束'`
- 火力等级 `'LV'` 为通用缩写,可保留不改
- _需求:8.1 ~ 8.3_
- [ ] 9. 全局UI布局验证与字体适配
- 检查所有场景中 Canvas 字体设置,确认中文渲染正常(如 `'Arial'` 字体对中文的支持)
- 验证中文文案替换后各场景的文本居中、按钮宽度、布局间距是否正常
- 对文案长度变化较大的位置(如 `'Share the room code with your friend'``'将房间号分享给好友'`)重点检查是否溢出
- 确保模板字符串中的动态变量插值逻辑未被破坏
- _需求:边界情况 1、2、3、5_
+301
View File
@@ -0,0 +1,301 @@
# AudioManager 音效系统说明文档
> **文件路径**: `js/managers/AudioManager.js`
> **运行环境**: 微信小游戏 (WeChat Mini Game)
> **依赖 API**: `wx.createWebAudioContext()` (Web Audio API)
> **外部资源**: 无 — 所有音效通过 PCM 程序化合成生成
---
## 1. 架构概述
`AudioManager` 是坦克大战微信小游戏的音效管理模块,采用 **程序化音频合成** 方案,通过 Web Audio API 在运行时生成所有游戏音效的 PCM 缓冲区,无需加载任何外部音频文件。
### 设计决策
| 方案 | 优点 | 缺点 |
|------|------|------|
| ~~外部音频文件~~ | 音质高、可定制 | 需要额外资源文件,增加包体积 |
| **程序化合成 ✅** | 零资源依赖、包体极小、即时可用 | 音效较简单,适合复古风格游戏 |
### 模块关系
```
game.js (初始化)
└── AudioManager.init() ← 创建 WebAudioContext + 预生成所有音效缓冲区
├── GameScene.js (游戏场景)
│ ├── _playerFire() → playSFX('shoot')
│ ├── _enemyFire() → playSFX('shoot')
│ ├── _spawnExplosion() → playSFX('explosion_big' | 'explosion_small')
│ ├── _checkPowerUpPickup() → playSFX('powerup')
│ ├── _handlePlayerDestroyed() → playSFX('gameover')
│ ├── _handleBaseDestroyed() → playSFX('gameover')
│ └── _checkVictory() → playSFX('victory')
└── CollisionManager.js (碰撞管理)
└── 子弹击中装甲坦克(未摧毁) → playSFX('hit')
```
---
## 2. 生命周期
```mermaid
sequenceDiagram
participant G as game.js
participant AM as AudioManager
participant GS as GameScene
G->>AM: new AudioManager()
G->>G: GameGlobal.audioManager = audioManager
G->>AM: audioManager.init()
AM->>AM: wx.createWebAudioContext()
AM->>AM: _generateSounds() 预生成9种音效
Note over AM: 初始化完成,_initialized = true
GS->>AM: playSFX('shoot')
AM->>AM: createBufferSource() → connect → start
Note over AM: 播放音效
G->>AM: pauseAll() / resumeAll()
Note over AM: 前后台切换时暂停/恢复
G->>AM: destroy()
AM->>AM: audioCtx.close() + buffers.clear()
```
---
## 3. 音效目录
### 3.1 完整音效列表
| 音效名 | 用途 | 时长 | 波形特征 | 触发位置 |
|--------|------|------|----------|----------|
| `shoot` | 坦克射击 | 80ms | 800→400Hz 下降正弦波 + 线性衰减 | `GameScene._playerFire()` / `_enemyFire()` |
| `explosion_small` | 小爆炸(子弹击中地形) | 200ms | 白噪声 + 120Hz 正弦波,二次衰减 | `GameScene._spawnExplosion(x, y, false)` |
| `explosion_big` | 大爆炸(坦克被摧毁) | 400ms | 白噪声 + 60Hz/90Hz 双正弦波,二次衰减 | `GameScene._spawnExplosion(x, y, true)` |
| `hit` | 子弹击中装甲(未摧毁) | 100ms | 1200Hz + 2400Hz 双正弦波,金属质感 | `CollisionManager` 子弹命中装甲坦克 |
| `hit_wall` | 子弹击中墙壁 | 60ms | 噪声 + 300Hz 正弦波混合 | 预留(当前未调用) |
| `powerup` | 拾取道具 | 250ms | 400→1200Hz 上升正弦波 + 泛音 | `GameScene._checkPowerUpPickup()` |
| `gameover` | 游戏结束 | 600ms | 400→150Hz 下降正弦波 | `GameScene._handlePlayerDestroyed()` / `_handleBaseDestroyed()` |
| `victory` | 通关胜利 | 500ms | C5→E5→G5 三音阶上升和弦 | `GameScene._checkVictory()` |
| `move` | 坦克移动 | 50ms | 80Hz 低频正弦波 | 预留(当前未调用) |
### 3.2 音效波形参数详解
#### shoot(射击)
```
时长: 0.08s
频率: 800Hz → 400Hz (线性下降)
包络: 线性衰减 (1 → 0)
振幅: 0.3
```
#### explosion_big(大爆炸)
```
时长: 0.4s
成分: 白噪声(50%) + 60Hz正弦(30%) + 90Hz正弦(20%)
包络: 二次衰减 (1-t)²
振幅: 0.4
```
#### victory(胜利)
```
时长: 0.5s
音符: C5(523Hz) → E5(659Hz) → G5(784Hz)
每段: 0.167s, 含 attack(10%) + decay(90%)
泛音: 基频 × 2, 振幅 0.15
```
---
## 4. API 参考
### 构造函数
```javascript
const audioManager = new AudioManager();
```
创建实例,默认 `soundEnabled = true`, `musicEnabled = true`
自动监听 `GameGlobal.eventBus``settings:changed` 事件。
### init()
```javascript
audioManager.init();
```
初始化 WebAudio 上下文并预生成所有音效缓冲区。
- 幂等调用:多次调用只执行一次
- 如果 `wx.createWebAudioContext` 不可用,静默降级(无音效)
### playSFX(name)
```javascript
GameGlobal.audioManager.playSFX('shoot');
```
播放指定名称的音效。
- **参数**: `name` — 音效名称,见上方音效目录
- 如果音效未找到或音效已禁用,静默忽略
- 每次调用创建新的 `BufferSource`,支持同一音效并发播放
### register(name, path)
```javascript
audioManager.register('custom', 'path/to/file.mp3');
```
向后兼容接口,当前为空操作(No-op)。
### playBGM(path) / stopBGM()
背景音乐接口,当前未实现(需要外部音频文件)。
### pauseAll() / resumeAll()
暂停/恢复所有音频,用于应用前后台切换。
### destroy()
销毁音频上下文,释放所有缓冲区资源。
### 属性
| 属性 | 类型 | 说明 |
|------|------|------|
| `soundEnabled` | `boolean` | 音效开关(getter/setter |
| `musicEnabled` | `boolean` | 音乐开关(getter/setter |
---
## 5. 集成指南
### 5.1 初始化(game.js
```javascript
// game.js 第39行
const audioManager = new AudioManager();
// 第48行 - 挂载到全局
GameGlobal.audioManager = audioManager;
// 第131行 - LoadingScene._startLoading() 中初始化
audioManager.init();
```
### 5.2 在游戏逻辑中播放音效
```javascript
// 射击时
GameGlobal.audioManager.playSFX('shoot');
// 爆炸时(根据大小选择音效)
GameGlobal.audioManager.playSFX(isBig ? 'explosion_big' : 'explosion_small');
// 拾取道具
GameGlobal.audioManager.playSFX('powerup');
// 游戏结束
GameGlobal.audioManager.playSFX('gameover');
// 胜利
GameGlobal.audioManager.playSFX('victory');
```
### 5.3 添加新音效
`_generateSounds()` 方法中添加新的缓冲区生成:
```javascript
// 示例:添加一个"警报"音效
this._buffers.set('alarm', this._generateBuffer(sampleRate, 0.3, (i, len) => {
const t = i / len;
const freq = 600 + Math.sin(t * 20) * 200; // 颤音效果
const envelope = 1 - t;
return Math.sin(2 * Math.PI * freq * i / sampleRate) * envelope * 0.3;
}));
```
然后在需要的地方调用:
```javascript
GameGlobal.audioManager.playSFX('alarm');
```
### 5.4 前后台切换处理
```javascript
// game.js 中已配置
wx.onHide(() => { audioManager.pauseAll(); });
wx.onShow(() => { audioManager.resumeAll(); });
```
---
## 6. 技术细节
### 6.1 PCM 缓冲区生成原理
每个音效通过 `_generateBuffer()` 方法生成:
1. 根据 `sampleRate`(通常 44100Hz)和 `duration` 计算总采样数
2. 创建单声道 `AudioBuffer`
3. 逐采样调用 `generator(sampleIndex, totalSamples)` 函数
4. 每个采样值范围 `[-1.0, 1.0]`
```
采样数 = sampleRate × duration
例: 44100 × 0.08 = 3528 个采样点 (shoot 音效)
```
### 6.2 播放机制
```javascript
playSFX(name) createBufferSource() connect(destination) start(0)
```
- 每次播放创建新的 `BufferSource` 节点(Web Audio API 要求,BufferSource 是一次性的)
- 支持同一音效的多实例并发播放(如连续射击)
- 播放完成后 `BufferSource` 自动被垃圾回收
### 6.3 性能考量
| 指标 | 数值 |
|------|------|
| 预生成缓冲区数量 | 9 个 |
| 最大单个缓冲区大小 | ~26KB (explosion_big, 0.4s × 44100 × 4bytes) |
| 总内存占用 | ~80KB |
| 初始化耗时 | < 10ms |
| 播放延迟 | < 1ms (预生成缓冲区,无需解码) |
### 6.4 降级策略
```
wx.createWebAudioContext 可用?
├── 是 → 正常初始化,生成所有音效
└── 否 → _initialized = false, 所有 playSFX() 静默返回
```
---
## 7. 已知限制与后续规划
### 当前限制
1. **无背景音乐**`playBGM()` 为空实现,需要外部音频文件支持
2. **音效较简单** — 程序化合成适合复古风格,无法达到高保真音质
3. **`hit_wall``move` 音效已生成但未集成** — 预留接口,可在后续版本中启用
4. **`pauseAll()` / `resumeAll()` 为空实现** — WebAudio 的 suspend/resume 可在后续补充
### 后续可优化方向
- [ ] 实现 `pauseAll()` / `resumeAll()` 使用 `audioCtx.suspend()` / `audioCtx.resume()`
- [ ] 集成 `hit_wall` 音效到 `CollisionManager` 的墙壁碰撞逻辑
- [ ] 集成 `move` 音效到坦克移动逻辑(需注意节流,避免频繁触发)
- [ ] 添加音量控制(通过 `GainNode`
- [ ] 支持外部音频文件加载,用于背景音乐
- [ ] 音效参数可配置化(从 JSON 配置文件读取波形参数)
@@ -0,0 +1,79 @@
# 坦克大战经典游戏 - 极简商业化方案
## 前言
针对“坦克大战”这类经典小游戏,其核心魅力在于**简单爽快**的玩法体验。在微信小游戏生态中,商业化必须做减法,坚持**“轻数值、重体验”**的原则。以下是一个极简化的商业化方案,旨在不破坏原版游戏“味道”的前提下实现变现。
---
## 一、 核心原则:做减法
- **去复杂化**:砍掉“坦克升级”、“技能树”、“装备强化”等重数值成长系统。
- **保留原味**:玩家玩的是“一发子弹消灭一个敌人”的爽快感,不是数值碾压。
- **变现隐形**:商业化不干扰核心玩法循环(移动 → 射击 → 躲避)。
---
## 二、 唯一的货币:金币(Gold)
**只保留一种货币**,彻底砍掉钻石、赛季币、碎片等复杂体系。
- **定位**:纯功能性货币,用于“续命”和“爽局”。
- **获取**:主要靠**看广告**(IAA),其次靠对局结算(少量)。
---
## 三、 极简商业化三板斧
### 1. 复活续关(核心变现点)
- **场景**:玩家坦克被击毁,弹出选项。
- **选项A(看广告)**:立即复活,保留当前关卡进度。
- **选项B(花金币)**:支付 **200金币** 立即复活(为不想看广告的玩家提供出口)。
- **逻辑**:这是玩家**付费意愿最强**的时刻(沉没成本高),转化率最高。
### 2. 局前Buff(小额消耗)
- **机制**:开局前可购买一次性增益(仅生效一局)。
- **道具**
- **护盾(100金币)**:开局自带一层护盾。
- **双倍火力(150金币)**:开局10秒内子弹威力翻倍。
- **目的**:制造小额金币缺口,促使用户看广告赚金币。
### 3. 去广告特权(唯一内购)
- **商品****¥18 永久去广告**。
- **权益**:免除所有激励视频(复活仍需消耗金币)。
- **目标用户**:真正热爱这款游戏、讨厌打断的核心玩家。
---
## 四、 广告与获取闭环
### 1. 赚金币的广告(IAA
- **双倍金币**:结算页面,看广告使本局金币收益×2。
- **免费金币**:主城“领金币”按钮,每日看3次广告,每次得100金。
### 2. 经济循环设计
玩游戏 → 死亡/想变强 → 看广告赚金币/充值买金币 → 购买复活/Buff → 继续游戏
- **免费玩家**:通过看广告(双倍/每日)获得金币,用于复活和Buff。
- **付费玩家**:直接充值购买金币包(如 ¥6=1000金),或购买“去广告特权”。
---
## 五、 数值设定(极简版)
| 项目 | 数值 | 说明 |
| :--- | :--- | :--- |
| **单局基础金币** | 50 | 通关奖励 |
| **复活消耗** | 200 | 约等于4局收益,制造缺口 |
| **广告双倍** | 100 | 极具吸引力 |
| **新手礼包** | ¥1=500金 | 破冰首充,送一次复活 |
---
## 六、 为什么这套方案更优?
1. **符合认知**:老玩家只关心“命”和“火力”,金币只用来买命,逻辑自洽。
2. **开发极快**:无需设计复杂的成长线和赛季任务。
3. **风险最低**:没有抽奖、宝箱等概率性玩法,合规性极高。
4. **体验无损**:广告是可选的(你可以选择慢慢攒金币),不会强迫玩家。
---
## 总结
对于经典坦克大战,**不要试图教育玩家接受复杂系统**。用“复活”和“Buff”这两个最原始的需求驱动广告与内购,才是最高效的商业化路径。这套方案在保持游戏原汁原味的同时,实现了IAA与IAP的双轨变现,适合快速验证和迭代。
+168
View File
@@ -0,0 +1,168 @@
# 《坦克大作战》微信小游戏商业化方案
## 一、 商业化总策略
采用 **“IAA(激励广告)+ IAP(内购)+ 社交裂变”** 三轨并行模式,以**非强制性、高转化**为核心原则,确保免费玩家体验,激励付费转化。
## 二、 激励视频广告(IAA)设计
### 1. 广告场景与收益估算
| 广告场景 | 触发时机 | 用户收益 | 预估eCPM | 预估日展示/用户 | 策略说明 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **复活续关** | 关卡失败时弹出 | 立即复活,保留当前火力 | ¥80-120 | 0.3-0.5次 | 核心收益点,转化率可达8-12% |
| **双倍结算** | 关卡胜利后结算前 | 本局金币/经验×2 | ¥60-100 | 0.2-0.4次 | 利用胜利喜悦心理 |
| **宝箱加速** | 开启稀有宝箱(4小时冷却) | 立即打开,无需等待 | ¥70-110 | 0.1-0.3次 | 时间焦虑型设计 |
| **体力恢复** | 体力耗尽时(每日上限5) | 恢复5点体力 | ¥50-80 | 0.5-0.8次 | 卡点设计,促活跃 |
| **免费礼包** | 每日签到/活动页面 | 随机道具包 | ¥40-70 | 0.5-1次 | 低压力广告入口 |
**收益测算**
假设 DAU 10万,人均日广告展示 2.5次,eCPM ¥80
- 日广告收入 = 100,000 × 2.5 × (80/1000) = **¥20,000/天**
- 月广告收入 ≈ ¥600,000
### 2. 广告体验优化
- **预加载**:在关卡加载时预载广告,减少等待时间
- **频次控制**:同一场景15分钟内不重复展示相同广告
- **奖励立即发放**:广告结束瞬间发放奖励,建立正反馈
## 三、 应用内购买(IAP)设计
### 1. 商品体系
| 商品类型 | 价格(微信代币) | 实际价值 | 目标用户 | 购买场景 |
| :--- | :--- | :--- | :--- | :--- |
| **去广告特权** | ¥30/永久 | 免除所有激励视频等待 | 核心玩家 | 游戏中期,广告频次较高时 |
| **月卡** | ¥12/月 | 每日领100钻石+专属头像框 | 中度玩家 | 新手期后,有留存意愿 |
| **钻石包** | ¥6/60钻<br>¥30/360钻<br>¥68/880钻 | 1:10兑换游戏币 | 所有玩家 | 皮肤购买、体力补充等 |
| **皮肤礼包** | ¥18-68 | 限定坦克皮肤+配套技能 | 外观党 | 赛季更新/节日活动 |
| **新手礼包** | ¥1 | 价值¥30道具组合 | 新用户 | 首次进入游戏24小时内 |
### 2. 内购转化策略
- **首充双倍**:首次充值任意金额,额外赠送等值钻石
- **连续累充**:连续7天每日充值,第7天送稀有皮肤
- **订阅制**:月卡自动续费,前3天可无条件退款
**收入测算**
假设付费率 3%ARPPU ¥50
- 月内购收入 = 100,000 DAU × 30 × 3% × 50 = **¥450,000/月**
## 四、 战斗通行证(Battle Pass)系统
### 1. 赛季设计
- **赛季时长**:4周(28天),契合微信小游戏用户节奏
- **免费通行证**:20级,基础奖励(金币、普通皮肤)
- **高级通行证**:¥18/赛季,40级,含:
- 3款限定坦克皮肤
- 专属头像框、聊天气泡
- 双倍任务经验加成
- 赛季结算额外30%金币
### 2. 任务体系
| 任务类型 | 免费版 | 高级版 | 设计目的 |
| :--- | :--- | :--- | :--- |
| 每日任务 | 3个,100经验/个 | 额外2个 | 促日活 |
| 每周任务 | 5个,500经验/个 | 额外3个 | 促周活 |
| 赛季成就 | 10个,1000经验/个 | 无差异 | 长期目标 |
**转化策略**
- 前10级免费体验高级奖励
- 赛季最后3天限时8折
- 分享给3位好友可获5折优惠券
**收入测算**
假设高级通行证购买率 5%
- 赛季收入 = 100,000 DAU × 5% × 18 = ¥90,000
- 年收入(13赛季)= ¥1,170,000
## 五、 社交裂变与分享变现
### 1. 分享激励体系
| 分享场景 | 分享者奖励 | 被邀请者奖励 | 防作弊机制 |
| :--- | :--- | :--- | :--- |
| **每日首次分享** | 50金币 | 无 | IP+设备去重 |
| **邀请新用户** | 200金币/人(上限5人) | 双倍经验卡(3天) | 需完成新手引导 |
| **分享战绩** | 概率得稀有道具 | 观看广告得金币 | 每日上限3次 |
| **组队成功** | 与好友组队完成3局,各得100钻石 | 同上 | 需对局时长>2分钟 |
### 2. 裂变活动
- **老带新活动**:邀请3位新用户,送永久限定皮肤
- **战队招募**:创建战队并招募10人,队长得500钻石
- **节日助力**:集齐5种道具可兑换大奖,需好友互赠
## 六、 数值与经济系统
### 1. 货币体系
| 货币 | 获取途径 | 主要消耗 | 兑换比例(设计) |
| :--- | :--- | :--- | :--- |
| **金币** | 对局奖励、每日任务、广告 | 升级坦克、购买基础道具 | 100金币 ≈ ¥0.1 |
| **钻石** | 充值、高级通行证、活动 | 购买皮肤、稀有道具、体力 | 1钻石 ≈ ¥0.1 |
| **赛季币** | 赛季任务、段位奖励 | 兑换往季限定皮肤 | 无直接充值 |
### 2. 付费节奏
| 阶段 | 商业化重点 | 付费点设计 |
| :--- | :--- | :--- |
| **新手期**<br>1-3天) | 建立付费习惯 | 1元首充礼包、去广告特权推荐 |
| **成长期**<br>4-14天) | 提高付费深度 | 月卡、钻石充值、皮肤促销 |
| **成熟期**<br>15天+) | 稳定收入 | 赛季通行证、限量皮肤、定制化内容 |
## 七、 商业化功能排期
| 版本 | 核心功能 | 预估收入贡献 | 开发周期 |
| :--- | :--- | :--- | :--- |
| **V1.0 基础版** | 激励视频(复活、双倍)、插屏广告 | 100%广告收入 | 2周 |
| **V1.5 内购版** | 钻石充值、去广告特权、基础皮肤 | 广告60% + 内购40% | 3周 |
| **V2.0 赛季版** | 战斗通行证、赛季任务、段位系统 | 广告40% + 内购40% + 通行证20% | 4周 |
| **V2.5 社交版** | 分享裂变、战队系统、社交皮肤 | 增加用户基数20-30% | 3周 |
## 八、 风险控制与合规
### 1. 未成年人保护
- **时间限制**22:00-8:00无法登录
- **消费限制**:月消费上限 ¥400,单次消费提示
- **广告频控**:未成年人每日广告展示不超过5次
### 2. 合规要点
- **概率公示**:所有抽奖、宝箱概率明确公示
- **退款政策**:符合微信小游戏退款规范
- **数据隐私**:明确告知数据收集范围,提供关闭选项
### 3. 反作弊策略
- **广告反刷**:同一广告源IP限制、设备指纹识别
- **交易监控**:异常大额充值人工审核
- **外挂检测**:对局数据上报,异常行为封禁
## 九、 收入预测模型
| 收入来源 | 月收入预测 | 占比 | 备注 |
| :--- | :--- | :--- | :--- |
| 激励视频广告 | ¥600,000 | 50% | 基于10万DAUeCPM ¥80 |
| 内购(充值+特权) | ¥450,000 | 37.5% | 付费率3%ARPPU ¥50 |
| 战斗通行证 | ¥90,000 | 7.5% | 购买率5%,单价¥18 |
| 其他(插屏等) | ¥60,000 | 5% | 补充收入 |
| **月总收入** | **¥1,200,000** | 100% | |
| **年化收入** | **¥14,400,000** | | 保守估计 |
**关键假设**
- DAU 10万,月留存 25%
- 人均日广告展示 2.5次
- 付费率 3%ARPPU ¥50
- 高级通行证购买率 5%
## 十、 优化与迭代策略
### 1. 数据监控指标
- **商业化漏斗**:曝光 → 点击 → 转化 → 付费
- **LTV/CAC**:生命周期价值/获客成本 > 3
- **付费分布**:鲸鱼用户(付费>¥1000)占比<1%
### 2. A/B测试策略
| 测试项 | 方案A | 方案B | 评估指标 |
| :--- | :--- | :--- | :--- |
| 广告位 | 结算前插屏+激励 | 仅激励视频 | ARPDAU |
| 定价 | 月卡¥12 | 月卡¥8 | 付费率、总收入 |
| 通行证 | 40级,¥18 | 30级,¥12 | 购买率、完赛率 |
### 3. 长期规划
- **季度**:稳定IAA+IAP双轨,ARPU达¥1.5
- **半年**:引入品牌广告、轻度联运
- **年度**:IP授权、周边衍生,非游戏收入占比>20%
**总结**:本方案通过**分层付费设计**(免费看广告→小额内购→订阅通行证)覆盖全用户层级,利用微信社交链**降低获客成本**,在保持玩法核心乐趣的同时,实现**月流水超百万**的商业目标。关键成功因素在于平衡广告频次与用户体验,通过赛季内容持续拉动活跃与付费。
+325
View File
@@ -0,0 +1,325 @@
/**
* game.js
* Entry point for Tank War WeChat mini game.
* Initializes canvas, sets up the game loop, and manages scene lifecycle.
*/
const SceneManager = require('./js/managers/SceneManager');
const ResourceManager = require('./js/managers/ResourceManager');
const StorageManager = require('./js/managers/StorageManager');
const AudioManager = require('./js/managers/AudioManager');
const NetworkManager = require('./js/managers/NetworkManager');
const AdManager = require('./js/managers/AdManager');
const ShareManager = require('./js/managers/ShareManager');
const CurrencyManager = require('./js/managers/CurrencyManager');
const PaymentManager = require('./js/managers/PaymentManager');
const ComplianceManager = require('./js/managers/ComplianceManager');
const BuffManager = require('./js/managers/BuffManager');
const EventBus = require('./js/base/EventBus');
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
DEVICE_PIXEL_RATIO,
COLORS,
SCENE,
} = require('./js/base/GameGlobal');
// ============================================================
// Canvas Setup
// ============================================================
const canvas = wx.createCanvas();
const ctx = canvas.getContext('2d');
// Handle high-DPI screens
canvas.width = SCREEN_WIDTH * DEVICE_PIXEL_RATIO;
canvas.height = SCREEN_HEIGHT * DEVICE_PIXEL_RATIO;
ctx.scale(DEVICE_PIXEL_RATIO, DEVICE_PIXEL_RATIO);
// ============================================================
// Core Singletons
// ============================================================
const sceneManager = new SceneManager();
const resourceManager = new ResourceManager();
const storageManager = new StorageManager();
const audioManager = new AudioManager();
const networkManager = new NetworkManager();
const eventBus = new EventBus();
// Expose globally so all modules can access
GameGlobal.canvas = canvas;
GameGlobal.ctx = ctx;
GameGlobal.sceneManager = sceneManager;
GameGlobal.resourceManager = resourceManager;
GameGlobal.storageManager = storageManager;
GameGlobal.audioManager = audioManager;
GameGlobal.networkManager = networkManager;
GameGlobal.eventBus = eventBus;
// Initialize ad and share managers (depend on storageManager being set)
const adManager = new AdManager();
const shareManager = new ShareManager();
const currencyManager = new CurrencyManager();
const paymentManager = new PaymentManager();
const complianceManager = new ComplianceManager();
const buffManager = new BuffManager();
GameGlobal.adManager = adManager;
GameGlobal.shareManager = shareManager;
GameGlobal.currencyManager = currencyManager;
GameGlobal.paymentManager = paymentManager;
GameGlobal.complianceManager = complianceManager;
GameGlobal.buffManager = buffManager;
// ============================================================
// Game State
// ============================================================
let isPaused = false;
let lastTimestamp = 0;
// ============================================================
// Touch Event Forwarding
// ============================================================
wx.onTouchStart((e) => {
sceneManager.handleTouch('touchstart', e);
});
wx.onTouchMove((e) => {
sceneManager.handleTouch('touchmove', e);
});
wx.onTouchEnd((e) => {
sceneManager.handleTouch('touchend', e);
});
// ============================================================
// Lifecycle: pause / resume on background switch
// ============================================================
wx.onHide(() => {
isPaused = true;
audioManager.pauseAll();
eventBus.emit('game:pause');
});
wx.onShow((res) => {
isPaused = false;
lastTimestamp = 0; // reset so dt doesn't spike
audioManager.resumeAll();
eventBus.emit('game:resume');
console.log(`[game.js] onShow fired: scene=${res && res.scene}, query=${JSON.stringify(res && res.query)}, shareTicket=${res && res.shareTicket}`);
// Check for teamId from invite card (3v3 mode)
const teamId = _extractTeamId(res && res.query);
if (teamId) {
_handleInviteTeamId(teamId);
} else {
// Fallback: also check launch options in case onShow query is empty on cold start
try {
const launchOptions = wx.getLaunchOptionsSync();
const fallbackTeamId = _extractTeamId(launchOptions && launchOptions.query);
if (fallbackTeamId) {
console.log(`[game.js] onShow query empty, but found teamId in launchOptions: ${fallbackTeamId}`);
_handleInviteTeamId(fallbackTeamId);
}
} catch (e) {}
}
});
/**
* Extract teamId from query parameter.
* WeChat may provide query as an Object ({teamId: 'xxx'}) or as a raw
* query-string ('teamId=xxx'). This helper handles both formats.
* @param {object|string|undefined} query
* @returns {string|null}
*/
function _extractTeamId(query) {
if (!query) return null;
// Log the raw query for debugging
try {
console.log(`[game.js] _extractTeamId raw query: ${JSON.stringify(query)}, type: ${typeof query}`);
} catch (e) {
console.log(`[game.js] _extractTeamId query type: ${typeof query}`);
}
// Case 1: query is already an object with teamId property
if (typeof query === 'object' && query.teamId) {
return query.teamId;
}
// Case 2: query is a string like 'teamId=T12345' or 'teamId=T12345&foo=bar'
if (typeof query === 'string') {
const match = query.match(/teamId=([^&]+)/);
if (match) return match[1];
}
// Case 3: query is an object but teamId might be nested in a raw string field
// Some WeChat versions put the whole query string in a single property
if (typeof query === 'object') {
const keys = Object.keys(query);
// If the object has a single key that looks like a query string
for (const key of keys) {
const combined = key + (query[key] ? '=' + query[key] : '');
const match = combined.match(/teamId=([^&]+)/);
if (match) return match[1];
}
}
return null;
}
/**
* Handle teamId from invite card (shared between onShow and cold launch).
* Navigates to TeamRoomScene if possible, otherwise stores as pending.
* @param {string} teamId
*/
function _handleInviteTeamId(teamId) {
if (!teamId) return;
// Avoid duplicate processing if already pending the same teamId
if (GameGlobal._pendingTeamId === teamId) {
console.log(`[game.js] teamId ${teamId} already pending, skipping duplicate`);
return;
}
console.log(`[game.js] Received teamId from invite: ${teamId}, currentScene: ${sceneManager._currentName}`);
// If already past loading, navigate directly to team room
if (sceneManager._currentScene && sceneManager._currentName !== SCENE.LOADING) {
console.log(`[game.js] Navigating directly to TeamRoomScene with teamId: ${teamId}`);
if (!sceneManager._scenes.has(SCENE.TEAM_ROOM)) {
const TeamRoomScene = require('./js/scenes/TeamRoomScene');
sceneManager.register(SCENE.TEAM_ROOM, TeamRoomScene);
}
sceneManager.switchTo(SCENE.TEAM_ROOM, { teamId });
GameGlobal._pendingTeamId = null;
} else {
// Still loading — store pending teamId for auto-navigation after load
console.log(`[game.js] Still loading, storing pendingTeamId: ${teamId}`);
GameGlobal._pendingTeamId = teamId;
}
}
// Check for teamId from cold launch (user opened game via invite card)
try {
const launchOptions = wx.getLaunchOptionsSync();
console.log(`[game.js] Cold launch options: scene=${launchOptions.scene}, query=${JSON.stringify(launchOptions.query)}, referrerInfo=${JSON.stringify(launchOptions.referrerInfo)}`);
const launchTeamId = _extractTeamId(launchOptions && launchOptions.query);
if (launchTeamId) {
_handleInviteTeamId(launchTeamId);
} else {
console.log('[game.js] No teamId found in cold launch options');
}
} catch (e) {
console.error('[game.js] getLaunchOptionsSync failed:', e);
}
// ============================================================
// Game Loop
// ============================================================
function gameLoop(timestamp) {
requestAnimationFrame(gameLoop);
if (isPaused) return;
// Calculate delta time in seconds, cap at 100ms to avoid spiral
if (lastTimestamp === 0) lastTimestamp = timestamp;
let dt = (timestamp - lastTimestamp) / 1000;
if (dt > 0.1) dt = 0.1;
lastTimestamp = timestamp;
// Clear screen
ctx.clearRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
ctx.fillStyle = COLORS.BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Update & render current scene
sceneManager.update(dt);
sceneManager.render(ctx);
}
// ============================================================
// Loading Scene (inline, minimal)
// ============================================================
const LoadingScene = {
_progress: 0,
enter() {
this._progress = 0;
this._startLoading();
},
exit() {},
async _startLoading() {
// Initialize audio system (programmatic synthesis, no files needed)
audioManager.init();
// Define all image assets to preload
// For now we use procedural drawing, so asset list is empty.
// Assets can be added later as the game grows.
const assets = [];
if (assets.length === 0) {
// No assets to load, go directly to menu
this._progress = 1;
// Use setTimeout to allow at least one render frame of loading screen
setTimeout(() => {
const MenuScene = require('./js/scenes/MenuScene');
sceneManager.register(SCENE.MENU, MenuScene);
// Register other scenes lazily as they are created
sceneManager.switchTo(SCENE.MENU);
}, 300);
return;
}
await resourceManager.loadImages(assets, (loaded, total) => {
this._progress = loaded / total;
});
const MenuScene = require('./js/scenes/MenuScene');
sceneManager.register(SCENE.MENU, MenuScene);
sceneManager.switchTo(SCENE.MENU);
},
update(dt) {},
render(ctx) {
// Draw loading screen
const cx = SCREEN_WIDTH / 2;
const cy = SCREEN_HEIGHT / 2;
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('坦克探险', cx, cy - 60);
// Progress bar background
const barW = SCREEN_WIDTH * 0.6;
const barH = 12;
const barX = cx - barW / 2;
const barY = cy;
ctx.fillStyle = '#333333';
ctx.fillRect(barX, barY, barW, barH);
// Progress bar fill
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.fillRect(barX, barY, barW * this._progress, barH);
// Loading text
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = '14px Arial';
ctx.fillText(`加载中... ${Math.floor(this._progress * 100)}%`, cx, cy + 30);
},
handleTouch() {},
};
// ============================================================
// Bootstrap
// ============================================================
sceneManager.register(SCENE.LOADING, LoadingScene);
sceneManager.switchTo(SCENE.LOADING);
// Start the game loop
requestAnimationFrame(gameLoop);
+10
View File
@@ -0,0 +1,10 @@
{
"deviceOrientation": "landscape",
"showStatusBar": false,
"networkTimeout": {
"request": 10000,
"connectSocket": 10000,
"uploadFile": 10000,
"downloadFile": 10000
}
}
+84
View File
@@ -0,0 +1,84 @@
/**
* EventBus.js
* Simple publish/subscribe event system for decoupled communication
* between game systems.
*/
class EventBus {
constructor() {
/** @type {Map<string, Array<{fn: Function, once: boolean}>>} */
this._listeners = new Map();
}
/**
* Subscribe to an event.
* @param {string} event
* @param {Function} fn
* @returns {Function} Unsubscribe function.
*/
on(event, fn) {
if (!this._listeners.has(event)) {
this._listeners.set(event, []);
}
const entry = { fn, once: false };
this._listeners.get(event).push(entry);
return () => this.off(event, fn);
}
/**
* Subscribe to an event, but only fire once.
* @param {string} event
* @param {Function} fn
*/
once(event, fn) {
if (!this._listeners.has(event)) {
this._listeners.set(event, []);
}
this._listeners.get(event).push({ fn, once: true });
}
/**
* Unsubscribe from an event.
* @param {string} event
* @param {Function} fn
*/
off(event, fn) {
const list = this._listeners.get(event);
if (!list) return;
const idx = list.findIndex((entry) => entry.fn === fn);
if (idx !== -1) list.splice(idx, 1);
}
/**
* Emit an event with optional data.
* @param {string} event
* @param {*} [data]
*/
emit(event, data) {
const list = this._listeners.get(event);
if (!list || list.length === 0) return;
// Iterate in reverse so we can safely remove "once" entries
for (let i = list.length - 1; i >= 0; i--) {
const entry = list[i];
entry.fn(data);
if (entry.once) {
list.splice(i, 1);
}
}
}
/**
* Remove all listeners for a specific event, or all events.
* @param {string} [event]
*/
clear(event) {
if (event) {
this._listeners.delete(event);
} else {
this._listeners.clear();
}
}
}
module.exports = EventBus;
+350
View File
@@ -0,0 +1,350 @@
/**
* GameGlobal.js
* Global constants, enums, and configuration for Tank War mini game.
*/
// ============================================================
// Screen & Canvas
// ============================================================
const systemInfo = wx.getSystemInfoSync();
const SCREEN_WIDTH = systemInfo.windowWidth;
const SCREEN_HEIGHT = systemInfo.windowHeight;
const DEVICE_PIXEL_RATIO = systemInfo.pixelRatio || 1;
// ============================================================
// Map Grid
// ============================================================
// TILE_SIZE is determined by screen height so the map fills vertically
const GRID_ROWS = 13;
const GRID_COLS = 21;
const TILE_SIZE = SCREEN_HEIGHT / GRID_ROWS;
const MAP_WIDTH = TILE_SIZE * GRID_COLS;
const MAP_HEIGHT = TILE_SIZE * GRID_ROWS;
// Center map on screen — controls (joystick & fire button) overlay on the map
const MAP_OFFSET_X = Math.floor((SCREEN_WIDTH - MAP_WIDTH) / 2);
const MAP_OFFSET_Y = Math.floor((SCREEN_HEIGHT - MAP_HEIGHT) / 2);
// ============================================================
// Terrain Types
// ============================================================
const TERRAIN = {
EMPTY: 0,
BRICK: 1,
STEEL: 2,
RIVER: 3,
FOREST: 4,
BASE: 5,
BASE_WALL: 6, // brick wall around base
};
// ============================================================
// Direction
// ============================================================
const DIRECTION = {
UP: 0,
DOWN: 1,
LEFT: 2,
RIGHT: 3,
};
// Direction vectors (dx, dy)
const DIR_VECTORS = {
[DIRECTION.UP]: { dx: 0, dy: -1 },
[DIRECTION.DOWN]: { dx: 0, dy: 1 },
[DIRECTION.LEFT]: { dx: -1, dy: 0 },
[DIRECTION.RIGHT]: { dx: 1, dy: 0 },
};
// ============================================================
// Tank Types
// ============================================================
const TANK_TYPE = {
PLAYER: 'player',
ENEMY_NORMAL: 'enemy_normal',
ENEMY_FAST: 'enemy_fast',
ENEMY_ARMOR: 'enemy_armor',
ENEMY_BOSS: 'enemy_boss',
};
// Tank configuration table
const TANK_CONFIG = {
[TANK_TYPE.PLAYER]: {
speed: 2,
hp: 1,
color: '#FFD700', // gold
size: TILE_SIZE * 0.85,
},
[TANK_TYPE.ENEMY_NORMAL]: {
speed: 1.5,
hp: 1,
color: '#AAAAAA', // gray
size: TILE_SIZE * 0.85,
score: 100,
},
[TANK_TYPE.ENEMY_FAST]: {
speed: 3,
hp: 1,
color: '#FF6347', // tomato
size: TILE_SIZE * 0.85,
score: 200,
},
[TANK_TYPE.ENEMY_ARMOR]: {
speed: 1,
hp: 3,
color: '#228B22', // forest green
size: TILE_SIZE * 0.85,
score: 300,
},
[TANK_TYPE.ENEMY_BOSS]: {
speed: 1.2,
hp: 6,
color: '#8B0000', // dark red
size: TILE_SIZE * 1.2,
score: 500,
},
};
// ============================================================
// Bullet
// ============================================================
const BULLET_SPEED = 5;
const BULLET_SIZE = 6;
// ============================================================
// Fire Level
// ============================================================
const FIRE_LEVEL = {
LV1: 1, // single shot, 1 bullet on screen
LV2: 2, // rapid fire, 2 bullets on screen
LV3: 3, // rapid fire + steel break, 2 bullets on screen
};
const MAX_BULLETS_BY_LEVEL = {
[FIRE_LEVEL.LV1]: 1,
[FIRE_LEVEL.LV2]: 2,
[FIRE_LEVEL.LV3]: 2,
};
// ============================================================
// Power-Up Types
// ============================================================
const POWERUP_TYPE = {
STAR: 'star',
CLOCK: 'clock',
BOMB: 'bomb',
HELMET: 'helmet',
SHOVEL: 'shovel',
TANK: 'tank',
};
const POWERUP_DURATION = 15000; // ms, how long a power-up stays on map
// ============================================================
// Game Settings
// ============================================================
const DEFAULT_LIVES = 3;
const ENEMIES_PER_LEVEL = 20;
const MAX_ENEMIES_ON_SCREEN = 4;
const ENEMY_SPAWN_INTERVAL = 3000; // ms
const FREEZE_DURATION = 10000; // ms
const SHIELD_DURATION = 15000; // ms
const SHOVEL_DURATION = 20000; // ms
const INVINCIBLE_BLINK_INTERVAL = 100; // ms
// ============================================================
// Scene Names
// ============================================================
const SCENE = {
LOADING: 'loading',
MENU: 'menu',
GAME: 'game',
RESULT: 'result',
RANKING: 'ranking',
SETTINGS: 'settings',
SHOP: 'shop',
BUFF_SELECT: 'buff_select',
PVP_ROOM: 'pvp_room',
PVP_GAME: 'pvp_game',
PVP_RESULT: 'pvp_result',
TEAM_ROOM: 'team_room',
TEAM_GAME: 'team_game',
TEAM_RESULT: 'team_result',
};
// ============================================================
// Game Modes
// ============================================================
const GAME_MODE = {
CLASSIC: 'classic',
ENDLESS: 'endless',
PVP: 'pvp',
TEAM_3V3: 'team_3v3',
};
// ============================================================
// PVP Settings
// ============================================================
const PVP_ROUND_TIME = 180; // seconds per round (legacy, unused in base-destruction mode)
const PVP_RESPAWN_DELAY = 3000; // ms before respawn
const PVP_MAX_LIVES = 5; // legacy, unused in base-destruction mode
const PVP_WIN_KILLS = 5; // legacy, unused in base-destruction mode
const PVP_BASE_HP = 5; // base hit points for 1v1 PVP mode
// ============================================================
// Server Configuration
// ============================================================
// const SERVER_URL = 'ws://192.168.1.103:3000'; // local testing server URL, replace with actual server URL in production
const SERVER_URL = 'wss://www.igeek.site/games/wx/tankwar';
// ============================================================
// 3v3 Team Settings
// ============================================================
const TEAM_SIZE = 3;
const TEAM_RESPAWN_DELAY = 3000; // ms before respawn
const TEAM_BASE_HP = 10; // base hit points for 3v3 mode
const TEAM_RECONNECT_TIMEOUT = 60000; // ms, 60s to reconnect
const TEAM_MATCH_TIMEOUT = 60000; // ms, 60s matchmaking timeout
// ============================================================
// Battle Configuration (X vs X configurable)
// ============================================================
const BATTLE_CONFIG = {
'1v1': {
teamSize: 1,
baseHp: PVP_BASE_HP,
respawnDelay: PVP_RESPAWN_DELAY,
fillWithBots: false,
mapPool: 'pvp',
},
'3v3': {
teamSize: 3,
baseHp: TEAM_BASE_HP,
respawnDelay: TEAM_RESPAWN_DELAY,
fillWithBots: true,
mapPool: 'team',
},
};
// ============================================================
// Network Message Types
// ============================================================
const NET_MSG = {
// Room
CREATE_ROOM: 'create_room',
JOIN_ROOM: 'join_room',
ROOM_CREATED: 'room_created',
ROOM_JOINED: 'room_joined',
ROOM_ERROR: 'room_error',
OPPONENT_JOINED: 'opponent_joined',
OPPONENT_LEFT: 'opponent_left',
GAME_START: 'game_start',
// Gameplay
PLAYER_INPUT: 'player_input',
PLAYER_STATE: 'player_state',
BULLET_FIRE: 'bullet_fire',
BULLET_HIT: 'bullet_hit',
PLAYER_HIT: 'player_hit',
PLAYER_KILLED: 'player_killed',
GAME_OVER: 'game_over',
// Sync
PING: 'ping',
PONG: 'pong',
SYNC_STATE: 'sync_state',
// Team (3v3)
CREATE_TEAM: 'create_team',
JOIN_TEAM: 'join_team',
LEAVE_TEAM: 'leave_team',
TEAM_READY: 'team_ready',
TEAM_KICK: 'team_kick',
TEAM_DISBAND: 'team_disband',
TEAM_STATE: 'team_state',
MATCH_START: 'match_start',
MATCH_CANCEL: 'match_cancel',
MATCH_FOUND: 'match_found',
MATCH_TIMEOUT: 'match_timeout',
BASE_HIT: 'base_hit',
BASE_DESTROYED: 'base_destroyed',
PLAYER_RESPAWN: 'player_respawn',
TEAM_GAME_START: 'team_game_start',
TEAM_GAME_OVER: 'team_game_over',
RECONNECT: 'reconnect',
RECONNECT_OK: 'reconnect_ok',
PLAYER_DISCONNECT: 'player_disconnect',
BOT_TAKEOVER: 'bot_takeover',
SOLO_MATCH: 'solo_match',
REMATCH: 'rematch',
REMATCH_READY: 'rematch_ready',
};
// ============================================================
// Colors
// ============================================================
const COLORS = {
BG: '#000000',
BRICK: '#B5651D',
STEEL: '#808080',
RIVER: '#4169E1',
FOREST: '#006400',
BASE: '#FFD700',
BASE_WALL: '#B5651D',
HUD_TEXT: '#FFFFFF',
MENU_BG: '#1a1a2e',
MENU_TITLE: '#FFD700',
MENU_BTN: '#16213e',
MENU_BTN_TEXT: '#FFFFFF',
MENU_BTN_BORDER: '#0f3460',
};
// ============================================================
// Export
// ============================================================
module.exports = {
SCREEN_WIDTH,
SCREEN_HEIGHT,
DEVICE_PIXEL_RATIO,
GRID_COLS,
GRID_ROWS,
TILE_SIZE,
MAP_WIDTH,
MAP_HEIGHT,
MAP_OFFSET_X,
MAP_OFFSET_Y,
TERRAIN,
DIRECTION,
DIR_VECTORS,
TANK_TYPE,
TANK_CONFIG,
BULLET_SPEED,
BULLET_SIZE,
FIRE_LEVEL,
MAX_BULLETS_BY_LEVEL,
POWERUP_TYPE,
POWERUP_DURATION,
DEFAULT_LIVES,
ENEMIES_PER_LEVEL,
MAX_ENEMIES_ON_SCREEN,
ENEMY_SPAWN_INTERVAL,
FREEZE_DURATION,
SHIELD_DURATION,
SHOVEL_DURATION,
INVINCIBLE_BLINK_INTERVAL,
SCENE,
GAME_MODE,
PVP_ROUND_TIME,
PVP_RESPAWN_DELAY,
PVP_MAX_LIVES,
PVP_WIN_KILLS,
PVP_BASE_HP,
TEAM_SIZE,
TEAM_RESPAWN_DELAY,
TEAM_BASE_HP,
TEAM_RECONNECT_TIMEOUT,
TEAM_MATCH_TIMEOUT,
BATTLE_CONFIG,
NET_MSG,
COLORS,
SERVER_URL,
};
+83
View File
@@ -0,0 +1,83 @@
/**
* ObjectPool.js
* Generic object pool for reusing frequently created/destroyed objects
* (bullets, explosions, etc.) to avoid GC pressure in WeChat mini game.
*/
class ObjectPool {
/**
* @param {Function} createFn - Factory function that returns a new object instance.
* @param {Function} [resetFn] - Optional function to reset an object before reuse.
* @param {number} [initialSize=0] - Number of objects to pre-allocate.
*/
constructor(createFn, resetFn, initialSize = 0) {
this._createFn = createFn;
this._resetFn = resetFn || null;
this._pool = [];
this._activeCount = 0;
// Pre-allocate
for (let i = 0; i < initialSize; i++) {
this._pool.push(this._createFn());
}
}
/**
* Get an object from the pool. Creates a new one if pool is empty.
* @returns {*} A reusable object instance.
*/
get() {
let obj;
if (this._pool.length > 0) {
obj = this._pool.pop();
} else {
obj = this._createFn();
}
if (this._resetFn) {
this._resetFn(obj);
}
this._activeCount++;
return obj;
}
/**
* Return an object back to the pool for future reuse.
* @param {*} obj - The object to recycle.
*/
put(obj) {
if (obj) {
this._pool.push(obj);
this._activeCount = Math.max(0, this._activeCount - 1);
}
}
/**
* Pre-allocate a number of objects into the pool.
* @param {number} count
*/
preAllocate(count) {
for (let i = 0; i < count; i++) {
this._pool.push(this._createFn());
}
}
/**
* Clear the pool entirely.
*/
clear() {
this._pool.length = 0;
this._activeCount = 0;
}
/** Number of objects currently in the pool (idle). */
get size() {
return this._pool.length;
}
/** Number of objects currently in use. */
get activeCount() {
return this._activeCount;
}
}
module.exports = ObjectPool;
+2
View File
@@ -0,0 +1,2 @@
// BattlePassData - DEPRECATED (removed in monetization-lite)
module.exports = {};
+525
View File
@@ -0,0 +1,525 @@
/**
* LevelData.js
* Predefined level map configurations.
* Each level is a 13×21 grid (rows × cols). Values correspond to TERRAIN enum:
* 0=EMPTY, 1=BRICK, 2=STEEL, 3=RIVER, 4=FOREST, 5=BASE, 6=BASE_WALL
*
* In landscape mode the map is 13 rows × 21 cols.
* The original 13×13 design sits in the center (cols 416),
* with additional terrain on the flanks (cols 03 and cols 1720).
*
* Player spawns at bottom area, enemies spawn from top row.
* Base (5) is at bottom-center, surrounded by BASE_WALL (6).
*/
const LEVELS = [
// ============================================================
// Level 1 - Tutorial: open terrain, few bricks
// ============================================================
{
id: 1,
name: 'Tutorial I',
grid: [
//0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,1,0,0,0,1,0,0,1,0,1,0,0,1,0,0,0,1,0,0],
[0,0,0,0,0,0,1,0,0,1,0,1,0,0,1,0,0,0,0,0,0],
[0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0],
[0,0,0,0,0,0,1,0,1,1,0,1,1,0,1,0,0,0,0,0,0],
[0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0],
[0,0,0,1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0,0,0],
[0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0],
[0,1,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,1,0],
[0,0,0,1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0,0,0],
[0,0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 18, fast: 2, armor: 0, boss: 0 },
},
speedMultiplier: 0.6,
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 2 - Tutorial II: more bricks, simple layout
// ============================================================
{
id: 2,
name: 'Tutorial II',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,1,0,0,0,1,1,0,1,1,0,1,1,0,1,1,0,0,0,1,0],
[0,0,0,1,0,1,1,0,1,1,0,1,1,0,1,1,0,1,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,1,0,0,1,0,0,1,0,1,0,1,0,1,0,0,1,0,0,1,0],
[0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,1,0,0,1,0,0,0,1,0,1,0,0,0,1,0,0,1,0,0],
[0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0],
[0,0,0,0,0,0,0,1,0,1,0,1,0,1,0,0,0,0,0,0,0],
[0,1,0,1,0,0,0,1,0,1,0,1,0,1,0,0,0,1,0,1,0],
[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 16, fast: 4, armor: 0, boss: 0 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 3 - Tutorial III: denser bricks, introduce forest
// ============================================================
{
id: 3,
name: 'Tutorial III',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,4,0,0,0,1,0,4,0,1,0,1,0,4,0,1,0,0,0,4,0],
[0,4,0,1,0,1,0,4,0,1,0,1,0,4,0,1,0,1,0,4,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,1,0,1,0,4,0,1,0,1,1,0,0,1,0,0],
[0,0,0,0,0,0,0,0,1,0,4,0,1,0,0,0,0,0,0,0,0],
[0,1,0,1,1,0,1,0,0,0,0,0,0,0,1,0,1,1,0,1,0],
[0,0,0,0,1,0,1,0,0,1,0,1,0,0,1,0,1,0,0,0,0],
[0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0],
[0,1,0,0,0,1,0,1,0,0,0,0,0,1,0,1,0,0,0,1,0],
[0,0,0,1,0,1,0,1,0,0,0,0,0,1,0,1,0,1,0,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 14, fast: 6, armor: 0, boss: 0 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 4 - Steel Fortress: steel walls appear
// ============================================================
{
id: 4,
name: 'Steel Fortress',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,2,0,0,1,2,0,1,1,0,1,1,0,2,1,0,0,2,0,0],
[0,0,0,0,0,1,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0],
[0,1,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,1,0],
[0,0,0,0,2,0,0,1,0,2,0,2,0,1,0,0,2,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,2,0,0,0,1,1,0,2,1,0,1,2,0,1,1,0,0,0,2,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,0,1,0,1,0,1,0,1,0,1,0,0,1,0,0],
[0,0,0,0,0,1,0,1,0,0,0,0,0,1,0,1,0,0,0,0,0],
[0,1,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,1,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 12, fast: 5, armor: 3, boss: 0 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 5 - River Crossing: introduces water terrain
// ============================================================
{
id: 5,
name: 'River Crossing',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0,1,0,0],
[0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0],
[0,1,0,0,0,0,0,3,3,0,1,0,3,3,0,0,0,0,0,1,0],
[0,0,0,1,0,1,0,3,3,0,0,0,3,3,0,1,0,1,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,1,0,0,1,0,1,0,3,1,0,1,3,0,1,0,1,0,0,1,0],
[0,0,0,0,0,0,0,0,3,0,0,0,3,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0],
[0,1,0,0,0,1,0,1,0,1,1,1,0,1,0,1,0,0,0,1,0],
[0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 10, fast: 6, armor: 4, boss: 0 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 10 - Iron Wall: lots of armored enemies
// ============================================================
{
id: 10,
name: 'Iron Wall',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,2,0,0,0,2,1,0,2,1,0,1,2,0,1,2,0,0,0,2,0],
[0,0,0,1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,1,0,0,1,2,0,1,3,3,0,3,3,1,0,2,1,0,0,1,0],
[0,0,0,0,0,0,0,1,3,3,0,3,3,1,0,0,0,0,0,0,0],
[0,0,2,0,0,1,0,0,0,2,0,2,0,0,0,1,0,0,2,0,0],
[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],
[0,1,0,1,0,0,0,2,0,1,4,1,0,2,0,0,0,1,0,1,0],
[0,0,0,0,0,1,0,0,0,1,4,1,0,0,0,1,0,0,0,0,0],
[0,0,1,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,1,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 6, fast: 4, armor: 10, boss: 0 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
// ============================================================
// Level 20 - Boss Battle: giant tank encounter
// ============================================================
{
id: 20,
name: 'Boss Battle',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,2,0,1,2,0,2,1,0,2,1,0,0,1,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,2,0,0,2,0,0,2,3,0,0,0,3,2,0,0,2,0,0,2,0],
[0,0,0,0,0,0,0,0,3,0,4,0,3,0,0,0,0,0,0,0,0],
[0,0,1,0,0,1,2,0,0,0,4,0,0,0,2,1,0,0,1,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,1,0,0,0,2,0,1,0,2,0,2,0,1,0,2,0,0,0,1,0],
[0,0,0,1,0,0,0,1,0,0,0,0,0,1,0,0,0,1,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,6,6,6,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,6,6,5,6,6,0,0,0,0,0,0,0,0],
],
enemies: {
total: 20,
composition: { normal: 8, fast: 4, armor: 6, boss: 2 },
},
spawnPoints: [
{ col: 0, row: 0 },
{ col: 10, row: 0 },
{ col: 20, row: 0 },
],
playerSpawn: { col: 8, row: 10 },
},
];
/**
* Get level data by level number.
* If the level doesn't exist, generate one based on the closest template
* with increased difficulty.
* @param {number} levelNum
* @returns {object} Level data
*/
function getLevelData(levelNum) {
// Direct match
const exact = LEVELS.find((l) => l.id === levelNum);
if (exact) return JSON.parse(JSON.stringify(exact));
// Find the closest template (highest id <= levelNum)
let template = LEVELS[0];
for (const l of LEVELS) {
if (l.id <= levelNum && l.id > template.id) {
template = l;
}
}
// Clone and adjust difficulty
const data = JSON.parse(JSON.stringify(template));
data.id = levelNum;
data.name = `Level ${levelNum}`;
// Scale enemy composition based on level
const cycle = Math.floor((levelNum - 1) / 20); // difficulty cycle
const extra = cycle * 2;
data.enemies.composition.armor = Math.min(
data.enemies.total,
data.enemies.composition.armor + extra
);
data.enemies.composition.fast = Math.min(
data.enemies.total - data.enemies.composition.armor,
data.enemies.composition.fast + cycle
);
data.enemies.composition.normal = Math.max(
0,
data.enemies.total -
data.enemies.composition.armor -
data.enemies.composition.fast -
data.enemies.composition.boss
);
return data;
}
// ============================================================
// PVP Maps - Symmetric layouts with bases for 1v1 base-destruction mode
// Each player has a base (TERRAIN.BASE=5) surrounded by BASE_WALL (6)
// Player 1 base on left, Player 2 base on right
// ============================================================
const PVP_MAPS = [
{
id: 1,
name: 'Arena I',
grid: [
//0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0],
[0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0],
[0,6,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,0],
[0,6,5,6,1,0,1,0,2,0,0,0,2,0,1,0,1,6,5,6,0],
[0,6,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,0],
[0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0],
[0,0,0,0,0,0,0,0,0,0,2,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,1,0,0,0,1,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
teamASpawns: [{ col: 1, row: 5 }],
teamBSpawns: [{ col: 19, row: 7 }],
},
{
id: 2,
name: 'Arena II',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,2,1,0,0,0,1,2,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,1,0,0,4,0,0,1,0,0,0,0,0,0,0],
[0,6,6,0,0,1,0,0,0,1,4,1,0,0,0,1,0,0,6,6,0],
[0,6,5,6,0,0,0,0,4,4,0,4,4,0,0,0,0,6,5,6,0],
[0,6,6,0,0,1,0,0,0,1,4,1,0,0,0,1,0,0,6,6,0],
[0,0,0,0,0,0,0,1,0,0,4,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,2,1,0,0,0,1,2,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,1,0,1,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
teamASpawns: [{ col: 0, row: 6 }],
teamBSpawns: [{ col: 20, row: 6 }],
},
{
id: 3,
name: 'Arena III',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,1,0,0,2,0,0,0,0,0,2,0,0,1,0,0,0,0],
[0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,3,0,0,0,3,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,3,0,2,0,3,0,0,0,0,0,0,0,0],
[0,6,6,1,0,2,0,0,0,0,0,0,0,0,0,2,0,1,6,6,0],
[0,6,5,6,0,0,0,1,0,4,4,4,0,1,0,0,0,6,5,6,0],
[0,6,6,1,0,2,0,0,0,0,0,0,0,0,0,2,0,1,6,6,0],
[0,0,0,0,0,0,0,0,3,0,2,0,3,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,3,0,0,0,3,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0],
[0,0,0,0,1,0,0,2,0,0,0,0,0,2,0,0,1,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
teamASpawns: [{ col: 1, row: 5 }],
teamBSpawns: [{ col: 19, row: 7 }],
},
];
/**
* Get a PVP map by id, or deterministically by roomId seed.
* When no mapId is given, uses roomId to pick the same map on both clients.
* Falls back to random only when neither mapId nor roomId is provided.
* @param {number} [mapId] - Explicit map id
* @param {string} [roomId] - Room id used as seed for deterministic selection
* @returns {object} PVP map data (deep clone).
*/
function getPvpMap(mapId, roomId) {
let map;
if (mapId) {
map = PVP_MAPS.find((m) => m.id === mapId);
}
if (!map && roomId) {
// Deterministic selection based on roomId so both clients pick the same map
let hash = 0;
for (let i = 0; i < roomId.length; i++) {
hash = ((hash << 5) - hash + roomId.charCodeAt(i)) | 0;
}
const index = Math.abs(hash) % PVP_MAPS.length;
map = PVP_MAPS[index];
}
if (!map) {
map = PVP_MAPS[Math.floor(Math.random() * PVP_MAPS.length)];
}
return JSON.parse(JSON.stringify(map));
}
// ============================================================
// 3v3 Team Maps - Symmetric layouts with bases on both ends
// Each team has a base (TERRAIN.BASE=5) surrounded by BASE_WALL (6)
// Left team base at left end, right team base at right end
// Center area is the contested zone
// ============================================================
const TEAM_MAPS = [
{
id: 1,
name: 'Battlefield I',
grid: [
//0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,1,0,0,1,0,2,0,2,0,1,0,0,1,0,0,0,0],
[0,6,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,0],
[0,6,5,6,0,2,0,0,3,3,0,3,3,0,0,2,0,6,5,6,0],
[0,6,6,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,6,6,0],
[0,0,0,0,1,0,0,1,0,2,0,2,0,1,0,0,1,0,0,0,0],
[0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
// Each team has exactly 1 base, centered vertically on their side
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
// Spawn points for Team A (left side, near base)
teamASpawns: [
{ col: 1, row: 5 },
{ col: 0, row: 6 },
{ col: 1, row: 7 },
],
// Spawn points for Team B (right side, near base)
teamBSpawns: [
{ col: 19, row: 5 },
{ col: 20, row: 6 },
{ col: 19, row: 7 },
],
},
{
id: 2,
name: 'Battlefield II',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,0,4,0,4,0,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,1,4,0,4,1,0,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,2,0,1,0,1,0,2,0,0,0,0,0,0,0],
[0,6,6,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,6,6,0],
[0,6,5,6,1,0,2,0,0,0,3,0,0,0,2,0,1,6,5,6,0],
[0,6,6,0,0,0,0,0,0,0,3,0,0,0,0,0,0,0,6,6,0],
[0,0,0,0,0,0,0,2,0,1,0,1,0,2,0,0,0,0,0,0,0],
[0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0],
[0,0,0,0,0,0,0,0,1,4,0,4,1,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,0,4,0,4,0,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
teamASpawns: [
{ col: 1, row: 5 },
{ col: 0, row: 6 },
{ col: 1, row: 7 },
],
teamBSpawns: [
{ col: 19, row: 5 },
{ col: 20, row: 6 },
{ col: 19, row: 7 },
],
},
{
id: 3,
name: 'Battlefield III',
grid: [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,0,0,0,1,0,0,2,0,0,0,0,0,2,0,0,1,0,0,0,0],
[0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,3,0,0,0,3,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,3,0,2,0,3,0,0,0,0,0,0,0,0],
[0,6,6,1,0,2,0,0,0,0,0,0,0,0,0,2,0,1,6,6,0],
[0,6,5,6,0,0,0,1,0,4,4,4,0,1,0,0,0,6,5,6,0],
[0,6,6,1,0,2,0,0,0,0,0,0,0,0,0,2,0,1,6,6,0],
[0,0,0,0,0,0,0,0,3,0,2,0,3,0,0,0,0,0,0,0,0],
[0,0,0,0,0,0,1,0,3,0,0,0,3,0,1,0,0,0,0,0,0],
[0,0,0,0,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0,0,0],
[0,0,0,0,1,0,0,2,0,0,0,0,0,2,0,0,1,0,0,0,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
],
teamABase: [{ col: 2, row: 6 }],
teamBBase: [{ col: 18, row: 6 }],
teamASpawns: [
{ col: 1, row: 5 },
{ col: 0, row: 6 },
{ col: 1, row: 7 },
],
teamBSpawns: [
{ col: 19, row: 5 },
{ col: 20, row: 6 },
{ col: 19, row: 7 },
],
},
];
/**
* Get a 3v3 team map by id or random.
* @param {number} [mapId] - Optional map id
* @returns {object} Team map data (deep clone).
*/
function getTeamMap(mapId) {
let map;
if (mapId) {
map = TEAM_MAPS.find((m) => m.id === mapId);
}
if (!map) {
map = TEAM_MAPS[Math.floor(Math.random() * TEAM_MAPS.length)];
}
return JSON.parse(JSON.stringify(map));
}
module.exports = { LEVELS, getLevelData, PVP_MAPS, getPvpMap, TEAM_MAPS, getTeamMap };
+2
View File
@@ -0,0 +1,2 @@
// SkinData - DEPRECATED (removed in monetization-lite)
module.exports = {};
+275
View File
@@ -0,0 +1,275 @@
/**
* BotTank.js
* AI-controlled bot tank for 3v3 team mode.
* Used to fill empty slots when matchmaking times out,
* or to take over disconnected players.
* Reuses EnemyTank-style AI logic adapted for team play.
*/
const Tank = require('./Tank');
const {
TANK_CONFIG,
TANK_TYPE,
DIRECTION,
DIR_VECTORS,
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
} = require('../base/GameGlobal');
/** AI States */
const BOT_STATE = {
PATROL: 'patrol',
ATTACK_BASE: 'attack_base',
DEFEND: 'defend',
};
class BotTank extends Tank {
/**
* @param {object} params
* @param {number} params.col - Spawn grid column.
* @param {number} params.row - Spawn grid row.
* @param {string} params.team - 'A' or 'B'.
* @param {string} params.playerId - Bot player id.
* @param {string} [params.color] - Tank color override.
*/
constructor(params) {
const cfg = TANK_CONFIG[TANK_TYPE.PLAYER];
const spawnX = MAP_OFFSET_X + params.col * TILE_SIZE + TILE_SIZE / 2;
const spawnY = MAP_OFFSET_Y + params.row * TILE_SIZE + TILE_SIZE / 2;
super({
x: spawnX,
y: spawnY,
speed: cfg.speed * 0.9, // Slightly slower than human players
hp: cfg.hp,
color: params.color || (params.team === 'A' ? '#4A90D9' : '#E94560'),
size: cfg.size,
direction: params.team === 'A' ? DIRECTION.RIGHT : DIRECTION.LEFT,
});
this.team = params.team;
this.playerId = params.playerId;
this.lives = 999; // Unlimited lives for 3v3
// AI state
this._botState = BOT_STATE.PATROL;
this._moveTimer = 0;
this._dirChangeInterval = 1.5 + Math.random() * 2;
this._shootTimer = 0;
this._shootInterval = 1.2 + Math.random() * 1.5;
this._stuckTimer = 0;
this._lastX = spawnX;
this._lastY = spawnY;
// Active bullets tracking
this.activeBullets = 0;
this._maxBullets = 1;
// Target (enemy base position)
this._targetBase = null;
// Shield (invincibility) — same as PlayerTank
this._shieldTimer = 0;
this._shieldBlink = false;
this._blinkTimer = 0;
}
/**
* Activate shield (invincibility).
* @param {number} duration - Duration in ms.
*/
activateShield(duration) {
this._shieldTimer = duration;
this._blinkTimer = 0;
this._shieldBlink = false;
}
/**
* Update bot tank state (shield timer etc.).
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (!this.alive) return;
// Shield timer (same as PlayerTank.update)
if (this._shieldTimer > 0) {
this._shieldTimer -= dt * 1000;
this._blinkTimer += dt * 1000;
if (this._blinkTimer >= 100) {
this._blinkTimer = 0;
this._shieldBlink = !this._shieldBlink;
}
if (this._shieldTimer <= 0) {
this._shieldTimer = 0;
this._shieldBlink = false;
}
}
}
/**
* Override takeDamage to check shield.
* @param {number} amount
* @returns {boolean} Whether destroyed.
*/
takeDamage(amount = 1) {
if (this._shieldTimer > 0) return false; // invincible
return super.takeDamage(amount);
}
/**
* Set the target base position for the bot to attack.
* @param {{x: number, y: number}} basePos
*/
setTargetBase(basePos) {
this._targetBase = basePos;
}
/**
* Update bot AI and state.
* @param {number} dt - Delta time in seconds.
* @param {MapManager} mapManager
* @param {Function} [onShoot] - Callback to fire a bullet.
*/
updateAI(dt, mapManager, onShoot) {
if (!this.alive) return;
// Movement AI
this._moveTimer += dt;
this._shootTimer += dt;
// Check if stuck
const moved = Math.abs(this.x - this._lastX) + Math.abs(this.y - this._lastY);
if (moved < 0.5) {
this._stuckTimer += dt;
} else {
this._stuckTimer = 0;
}
this._lastX = this.x;
this._lastY = this.y;
// Determine AI behavior
if (Math.random() < 0.5 && this._targetBase) {
this._botState = BOT_STATE.ATTACK_BASE;
} else {
this._botState = BOT_STATE.PATROL;
}
// Direction change
if (this._moveTimer >= this._dirChangeInterval || this._stuckTimer > 0.5) {
this._moveTimer = 0;
this._stuckTimer = 0;
this._chooseDirection(mapManager);
}
// Move
this.move(this.direction, dt, mapManager);
// Shoot
if (this._shootTimer >= this._shootInterval) {
this._shootTimer = 0;
this._shootInterval = 1 + Math.random() * 1.5;
if (this.activeBullets < this._maxBullets && onShoot) {
onShoot(this);
}
}
}
/**
* Choose a new direction based on AI state.
* @private
*/
_chooseDirection(mapManager) {
if (this._botState === BOT_STATE.ATTACK_BASE && this._targetBase) {
this._chaseTarget(this._targetBase, mapManager);
} else {
this._randomDirection();
}
}
/**
* Chase a target position (enemy base).
* @private
*/
_chaseTarget(target, mapManager) {
const dx = target.x - this.x;
const dy = target.y - this.y;
const dirs = [];
if (Math.abs(dx) > Math.abs(dy)) {
dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT);
dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP);
} else {
dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP);
dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT);
}
const allDirs = [DIRECTION.UP, DIRECTION.DOWN, DIRECTION.LEFT, DIRECTION.RIGHT];
for (const d of allDirs) {
if (!dirs.includes(d)) dirs.push(d);
}
// Try each direction, pick the first that isn't blocked
for (const dir of dirs) {
const vec = DIR_VECTORS[dir];
const testX = this.x + vec.dx * TILE_SIZE;
const testY = this.y + vec.dy * TILE_SIZE;
const left = testX - this.halfSize;
const top = testY - this.halfSize;
if (mapManager && !mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) {
this.direction = dir;
return;
}
}
// Fallback: random
this.direction = allDirs[Math.floor(Math.random() * allDirs.length)];
}
/**
* Choose a random direction with bias towards enemy base side.
* @private
*/
_randomDirection() {
const dirs = [DIRECTION.UP, DIRECTION.DOWN, DIRECTION.LEFT, DIRECTION.RIGHT];
// Bias towards enemy base direction
if (Math.random() < 0.4) {
// Team A bots go right, Team B bots go left
this.direction = this.team === 'A' ? DIRECTION.RIGHT : DIRECTION.LEFT;
return;
}
this.direction = dirs[Math.floor(Math.random() * dirs.length)];
}
/** Whether this bot can fire. */
canFire() {
return this.alive && this.activeBullets < this._maxBullets;
}
/** Check if this bot can break steel (always false for bots). */
canBreakSteel() {
return false;
}
/**
* Render with bot indicator.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive) return;
super.render(ctx);
// Bot indicator (small robot icon above tank)
ctx.fillStyle = '#AAAAAA';
ctx.font = '8px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('🤖', this.x, this.y - this.halfSize - 6);
}
}
module.exports = BotTank;
+119
View File
@@ -0,0 +1,119 @@
/**
* Bullet.js
* Bullet entity that travels in a straight line and interacts with terrain/tanks.
*/
const {
BULLET_SPEED,
BULLET_SIZE,
DIRECTION,
DIR_VECTORS,
MAP_OFFSET_X,
MAP_OFFSET_Y,
MAP_WIDTH,
MAP_HEIGHT,
} = require('../base/GameGlobal');
class Bullet {
constructor() {
this.x = 0;
this.y = 0;
this.direction = DIRECTION.UP;
this.speed = BULLET_SPEED;
this.size = BULLET_SIZE;
this.halfSize = BULLET_SIZE / 2;
this.alive = false;
this.canBreakSteel = false;
/** @type {'player'|'enemy'} */
this.owner = 'player';
/** @type {object|null} Reference to the tank that fired this bullet */
this.ownerTank = null;
}
/**
* Initialize/reset the bullet for reuse from object pool.
* @param {object} config
* @param {number} config.x
* @param {number} config.y
* @param {number} config.direction
* @param {string} config.owner - 'player' or 'enemy'
* @param {boolean} [config.canBreakSteel]
* @param {object} [config.ownerTank]
*/
init(config) {
this.x = config.x;
this.y = config.y;
this.direction = config.direction;
this.owner = config.owner || 'player';
this.canBreakSteel = config.canBreakSteel || false;
this.ownerTank = config.ownerTank || null;
this.alive = true;
}
/**
* Update bullet position.
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (!this.alive) return;
const vec = DIR_VECTORS[this.direction];
const moveAmount = this.speed * dt * 60;
this.x += vec.dx * moveAmount;
this.y += vec.dy * moveAmount;
// Check map boundaries
if (
this.x < MAP_OFFSET_X ||
this.y < MAP_OFFSET_Y ||
this.x > MAP_OFFSET_X + MAP_WIDTH ||
this.y > MAP_OFFSET_Y + MAP_HEIGHT
) {
this.destroy();
}
}
/**
* Render the bullet.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive) return;
ctx.fillStyle = this.owner === 'player' ? '#FFFF00' : '#FF6600';
ctx.fillRect(
this.x - this.halfSize,
this.y - this.halfSize,
this.size,
this.size
);
}
/**
* Get bounding box.
* @returns {{x: number, y: number, w: number, h: number}}
*/
getBounds() {
return {
x: this.x - this.halfSize,
y: this.y - this.halfSize,
w: this.size,
h: this.size,
};
}
/**
* Destroy the bullet (mark for recycling).
*/
destroy() {
this.alive = false;
// Decrement owner's active bullet count
if (this.ownerTank && typeof this.ownerTank.activeBullets === 'number') {
this.ownerTank.activeBullets = Math.max(0, this.ownerTank.activeBullets - 1);
}
}
}
module.exports = Bullet;
+269
View File
@@ -0,0 +1,269 @@
/**
* EnemyTank.js
* Enemy tank with AI behavior: patrol, chase, and attack states.
*/
const Tank = require('./Tank');
const {
TANK_TYPE,
TANK_CONFIG,
DIRECTION,
DIR_VECTORS,
GRID_COLS,
GRID_ROWS,
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
} = require('../base/GameGlobal');
/** AI States */
const AI_STATE = {
PATROL: 'patrol',
CHASE: 'chase',
ATTACK: 'attack',
};
class EnemyTank extends Tank {
/**
* @param {object} params
* @param {string} params.type - TANK_TYPE enum value.
* @param {number} params.col - Spawn grid column.
* @param {number} params.row - Spawn grid row.
* @param {number} [params.levelNum] - Current level number (affects AI).
* @param {boolean} [params.hasPowerUp] - Whether destroying this tank drops a power-up.
*/
constructor(params) {
const cfg = TANK_CONFIG[params.type];
const spawnX = MAP_OFFSET_X + params.col * TILE_SIZE + TILE_SIZE / 2;
const spawnY = MAP_OFFSET_Y + params.row * TILE_SIZE + TILE_SIZE / 2;
const speedMul = params.speedMultiplier || 1;
super({
x: spawnX,
y: spawnY,
speed: cfg.speed * speedMul,
hp: cfg.hp,
color: cfg.color,
size: cfg.size,
direction: DIRECTION.DOWN,
});
this.type = params.type;
this.score = cfg.score || 100;
this.hasPowerUp = params.hasPowerUp || false;
this.levelNum = params.levelNum || 1;
// AI state
this._aiState = AI_STATE.PATROL;
this._moveTimer = 0;
this._dirChangeInterval = 1.5 + Math.random() * 2; // seconds
this._shootTimer = 0;
this._shootInterval = 1 + Math.random() * 1.5; // seconds
this._stuckTimer = 0;
this._lastX = spawnX;
this._lastY = spawnY;
// Frozen state (from clock power-up)
this.frozen = false;
// Active bullets tracking
this.activeBullets = 0;
this._maxBullets = params.type === TANK_TYPE.ENEMY_BOSS ? 2 : 1;
// HP indicator blink for armored tanks
this._hitBlink = 0;
}
/**
* Update enemy tank AI and state.
* @param {number} dt - Delta time in seconds.
* @param {MapManager} mapManager
* @param {{x: number, y: number}} basePos - Base position for targeting.
* @param {Function} onShoot - Callback to fire a bullet.
*/
update(dt, mapManager, basePos, onShoot) {
if (!this.alive || this.frozen) return;
// Hit blink effect
if (this._hitBlink > 0) {
this._hitBlink -= dt;
}
// Movement AI
this._moveTimer += dt;
this._shootTimer += dt;
// Check if stuck
const moved = Math.abs(this.x - this._lastX) + Math.abs(this.y - this._lastY);
if (moved < 0.5) {
this._stuckTimer += dt;
} else {
this._stuckTimer = 0;
}
this._lastX = this.x;
this._lastY = this.y;
// Determine AI behavior based on level
if (this.levelNum >= 10 && this.type !== TANK_TYPE.ENEMY_NORMAL) {
this._aiState = AI_STATE.CHASE;
} else if (Math.random() < 0.3) {
this._aiState = AI_STATE.CHASE;
} else {
this._aiState = AI_STATE.PATROL;
}
// Direction change
if (this._moveTimer >= this._dirChangeInterval || this._stuckTimer > 0.5) {
this._moveTimer = 0;
this._stuckTimer = 0;
this._chooseDirection(mapManager, basePos);
}
// Move
this.move(this.direction, dt, mapManager);
// Shoot
if (this._shootTimer >= this._shootInterval) {
this._shootTimer = 0;
this._shootInterval = 0.8 + Math.random() * 1.5;
if (this.activeBullets < this._maxBullets && onShoot) {
onShoot(this);
}
}
}
/**
* Choose a new direction based on AI state.
* @private
*/
_chooseDirection(mapManager, basePos) {
if (this._aiState === AI_STATE.CHASE && basePos) {
// Move towards base
this._chaseTarget(basePos, mapManager);
} else {
// Random patrol
this._randomDirection(mapManager);
}
}
/**
* Chase a target position (usually the base).
* @private
*/
_chaseTarget(target, mapManager) {
const dx = target.x - this.x;
const dy = target.y - this.y;
// Prefer the axis with greater distance
const dirs = [];
if (Math.abs(dy) > Math.abs(dx)) {
dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP);
dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT);
} else {
dirs.push(dx > 0 ? DIRECTION.RIGHT : DIRECTION.LEFT);
dirs.push(dy > 0 ? DIRECTION.DOWN : DIRECTION.UP);
}
// Add random alternatives for variety
const allDirs = [DIRECTION.UP, DIRECTION.DOWN, DIRECTION.LEFT, DIRECTION.RIGHT];
for (const d of allDirs) {
if (!dirs.includes(d)) dirs.push(d);
}
// Try each direction, pick the first that isn't immediately blocked
for (const dir of dirs) {
const vec = DIR_VECTORS[dir];
const testX = this.x + vec.dx * TILE_SIZE;
const testY = this.y + vec.dy * TILE_SIZE;
const left = testX - this.halfSize;
const top = testY - this.halfSize;
if (!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) {
this.direction = dir;
return;
}
}
// Fallback: random
this.direction = allDirs[Math.floor(Math.random() * allDirs.length)];
}
/**
* Choose a random direction.
* @private
*/
_randomDirection(mapManager) {
const dirs = [DIRECTION.UP, DIRECTION.DOWN, DIRECTION.LEFT, DIRECTION.RIGHT];
// Bias towards down (towards base) 40% of the time
if (Math.random() < 0.4) {
this.direction = DIRECTION.DOWN;
return;
}
this.direction = dirs[Math.floor(Math.random() * dirs.length)];
}
/**
* Override takeDamage to add hit blink.
*/
takeDamage(amount = 1) {
this._hitBlink = 0.15;
return super.takeDamage(amount);
}
/**
* Render with HP indicator for armored tanks.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive) return;
// Hit blink effect
if (this._hitBlink > 0) {
ctx.save();
ctx.globalAlpha = 0.5;
super.render(ctx);
ctx.restore();
} else {
super.render(ctx);
}
// Power-up indicator (flashing border)
if (this.hasPowerUp) {
ctx.save();
ctx.strokeStyle = '#FF0000';
ctx.lineWidth = 2;
const blink = Math.sin(Date.now() / 150) > 0;
if (blink) {
ctx.strokeRect(
this.x - this.halfSize - 2,
this.y - this.halfSize - 2,
this.size + 4,
this.size + 4
);
}
ctx.restore();
}
// HP bar for armored/boss tanks
if (this.maxHp > 1) {
const barW = this.size;
const barH = 3;
const barX = this.x - this.halfSize;
const barY = this.y - this.halfSize - 6;
ctx.fillStyle = '#333333';
ctx.fillRect(barX, barY, barW, barH);
ctx.fillStyle = this.hp > this.maxHp * 0.3 ? '#00FF00' : '#FF0000';
ctx.fillRect(barX, barY, barW * (this.hp / this.maxHp), barH);
}
}
/** Whether this enemy can fire. */
canFire() {
return this.alive && !this.frozen && this.activeBullets < this._maxBullets;
}
}
module.exports = EnemyTank;
+104
View File
@@ -0,0 +1,104 @@
/**
* Explosion.js
* Simple explosion effect using frame-based animation.
* Managed via object pool for performance.
*/
class Explosion {
constructor() {
this.x = 0;
this.y = 0;
this.alive = false;
this.size = 0;
this.maxSize = 30;
this._timer = 0;
this._duration = 0.3; // seconds
this._phase = 0; // 0 to 1
this._isBig = false; // big explosion for tank destruction
}
/**
* Initialize the explosion.
* @param {number} x - Center X.
* @param {number} y - Center Y.
* @param {boolean} [isBig=false] - Whether this is a large explosion (tank death).
*/
init(x, y, isBig = false) {
this.x = x;
this.y = y;
this.alive = true;
this._timer = 0;
this._phase = 0;
this._isBig = isBig;
this.maxSize = isBig ? 50 : 25;
this._duration = isBig ? 0.5 : 0.3;
}
/**
* Update explosion animation.
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (!this.alive) return;
this._timer += dt;
this._phase = this._timer / this._duration;
if (this._phase >= 1) {
this.alive = false;
return;
}
// Size grows then shrinks
if (this._phase < 0.4) {
this.size = this.maxSize * (this._phase / 0.4);
} else {
this.size = this.maxSize * (1 - (this._phase - 0.4) / 0.6);
}
}
/**
* Render the explosion.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive) return;
ctx.save();
const alpha = 1 - this._phase * 0.5;
ctx.globalAlpha = alpha;
// Outer glow
if (this._isBig) {
ctx.fillStyle = '#FF4500';
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 1.2, 0, Math.PI * 2);
ctx.fill();
}
// Main explosion
ctx.fillStyle = '#FF8C00';
ctx.beginPath();
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
ctx.fill();
// Inner bright core
ctx.fillStyle = '#FFFF00';
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 0.5, 0, Math.PI * 2);
ctx.fill();
// White center flash (early phase only)
if (this._phase < 0.3) {
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.arc(this.x, this.y, this.size * 0.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
}
module.exports = Explosion;
+209
View File
@@ -0,0 +1,209 @@
/**
* PlayerTank.js
* Player-controlled tank with fire level, lives, shield, and respawn logic.
*/
const Tank = require('./Tank');
const {
TANK_TYPE,
TANK_CONFIG,
FIRE_LEVEL,
MAX_BULLETS_BY_LEVEL,
DIRECTION,
DEFAULT_LIVES,
SHIELD_DURATION,
INVINCIBLE_BLINK_INTERVAL,
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
} = require('../base/GameGlobal');
class PlayerTank extends Tank {
/**
* @param {object} params
* @param {number} params.col - Spawn grid column.
* @param {number} params.row - Spawn grid row.
*/
constructor(params) {
const cfg = TANK_CONFIG[TANK_TYPE.PLAYER];
const spawnX = MAP_OFFSET_X + params.col * TILE_SIZE + TILE_SIZE / 2;
const spawnY = MAP_OFFSET_Y + params.row * TILE_SIZE + TILE_SIZE / 2;
super({
x: spawnX,
y: spawnY,
speed: cfg.speed,
hp: cfg.hp,
color: cfg.color,
size: cfg.size,
direction: DIRECTION.UP,
});
this.type = TANK_TYPE.PLAYER;
this.spawnCol = params.col;
this.spawnRow = params.row;
// Skin colors (reserved for future use)
this._skinColors = null;
// Fire level system
this.fireLevel = FIRE_LEVEL.LV1;
// Lives
this.lives = DEFAULT_LIVES;
// Shield (invincibility)
this._shieldTimer = 0; // ms remaining
this._shieldBlink = false;
this._blinkTimer = 0;
// Active bullets count (managed externally)
this.activeBullets = 0;
// Respawn invincibility (short shield on spawn)
this._respawnShieldDuration = 3000; // 3 seconds on respawn
}
/**
* Update player tank state.
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (!this.alive) return;
// Shield timer
if (this._shieldTimer > 0) {
this._shieldTimer -= dt * 1000;
this._blinkTimer += dt * 1000;
if (this._blinkTimer >= INVINCIBLE_BLINK_INTERVAL) {
this._blinkTimer = 0;
this._shieldBlink = !this._shieldBlink;
}
if (this._shieldTimer <= 0) {
this._shieldTimer = 0;
this._shieldBlink = false;
}
}
}
/**
* Override takeDamage to check shield.
* @param {number} amount
* @returns {boolean} Whether destroyed.
*/
takeDamage(amount = 1) {
if (this._shieldTimer > 0) return false; // invincible
return super.takeDamage(amount);
}
/**
* Handle player death: lose a life and respawn, or game over.
* @returns {boolean} True if player has lives remaining and respawned.
*/
die() {
this.alive = false;
this.lives--;
if (this.lives > 0) {
this.respawn();
return true;
}
return false; // game over
}
/**
* Respawn at the spawn point with temporary invincibility.
*/
respawn() {
this.x = MAP_OFFSET_X + this.spawnCol * TILE_SIZE + TILE_SIZE / 2;
this.y = MAP_OFFSET_Y + this.spawnRow * TILE_SIZE + TILE_SIZE / 2;
this.direction = DIRECTION.UP;
this.hp = 1;
this.alive = true;
this.visible = true;
this.fireLevel = FIRE_LEVEL.LV1;
this.activeBullets = 0;
// Temporary shield on respawn
this.activateShield(this._respawnShieldDuration);
}
/**
* Activate shield (invincibility).
* @param {number} duration - Duration in ms.
*/
activateShield(duration) {
this._shieldTimer = duration;
this._blinkTimer = 0;
this._shieldBlink = false;
}
/**
* Upgrade fire level.
*/
upgradeFireLevel() {
if (this.fireLevel < FIRE_LEVEL.LV3) {
this.fireLevel++;
}
}
/**
* Add a life.
*/
addLife() {
this.lives++;
}
/**
* Check if the player can fire (based on active bullets and fire level).
* @returns {boolean}
*/
canFire() {
return this.alive && this.activeBullets < MAX_BULLETS_BY_LEVEL[this.fireLevel];
}
/**
* Whether the bullet should break steel.
* @returns {boolean}
*/
canBreakSteel() {
return this.fireLevel >= FIRE_LEVEL.LV3;
}
/**
* Render player tank with shield effect.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive) return;
// Call base render
super.render(ctx);
// Draw shield effect
if (this._shieldTimer > 0 && this._shieldBlink) {
ctx.save();
ctx.strokeStyle = '#00FFFF';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.arc(this.x, this.y, this.halfSize + 4, 0, Math.PI * 2);
ctx.stroke();
ctx.restore();
}
}
/** Whether the player is currently shielded. */
get isShielded() {
return this._shieldTimer > 0;
}
/** Get max bullets allowed on screen. */
get maxBullets() {
return MAX_BULLETS_BY_LEVEL[this.fireLevel];
}
}
module.exports = PlayerTank;
+188
View File
@@ -0,0 +1,188 @@
/**
* PowerUp.js
* Power-up item entity with type, position, timer, and blink animation.
*/
const {
POWERUP_TYPE,
POWERUP_DURATION,
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
MAP_WIDTH,
MAP_HEIGHT,
GRID_COLS,
GRID_ROWS,
} = require('../base/GameGlobal');
/** Power-up visual config */
const POWERUP_VISUALS = {
[POWERUP_TYPE.STAR]: { emoji: '⭐', color: '#FFD700', label: 'STAR' },
[POWERUP_TYPE.CLOCK]: { emoji: '🕒', color: '#87CEEB', label: 'CLOCK' },
[POWERUP_TYPE.BOMB]: { emoji: '💣', color: '#FF4500', label: 'BOMB' },
[POWERUP_TYPE.HELMET]: { emoji: '🛡️', color: '#00CED1', label: 'SHIELD' },
[POWERUP_TYPE.SHOVEL]: { emoji: '🏠', color: '#8B4513', label: 'SHOVEL' },
[POWERUP_TYPE.TANK]: { emoji: '+1', color: '#32CD32', label: 'LIFE' },
};
class PowerUp {
/**
* @param {string} type - POWERUP_TYPE value.
* @param {number} x - Pixel X.
* @param {number} y - Pixel Y.
*/
constructor(type, x, y) {
this.type = type;
this.x = x;
this.y = y;
this.alive = true;
this.size = TILE_SIZE * 0.9;
this.halfSize = this.size / 2;
this._timer = 0;
this._duration = POWERUP_DURATION;
this._blinkStart = POWERUP_DURATION * 0.7; // start blinking at 70% of duration
this._visible = true;
this._blinkTimer = 0;
}
/**
* Update power-up timer and blink.
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (!this.alive) return;
this._timer += dt * 1000;
// Blink when about to expire
if (this._timer >= this._blinkStart) {
this._blinkTimer += dt * 1000;
if (this._blinkTimer >= 150) {
this._blinkTimer = 0;
this._visible = !this._visible;
}
}
// Expire
if (this._timer >= this._duration) {
this.alive = false;
}
}
/**
* Render the power-up.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive || !this._visible) return;
const visual = POWERUP_VISUALS[this.type];
const x = this.x - this.halfSize;
const y = this.y - this.halfSize;
// Background glow
ctx.save();
ctx.globalAlpha = 0.3;
ctx.fillStyle = visual.color;
ctx.beginPath();
ctx.arc(this.x, this.y, this.halfSize + 4, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Background box
ctx.fillStyle = '#000000';
ctx.globalAlpha = 0.7;
ctx.fillRect(x, y, this.size, this.size);
ctx.globalAlpha = 1;
// Border
ctx.strokeStyle = visual.color;
ctx.lineWidth = 2;
ctx.strokeRect(x, y, this.size, this.size);
// Icon/text
ctx.fillStyle = visual.color;
ctx.font = `bold ${Math.floor(this.size * 0.5)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
if (this.type === POWERUP_TYPE.TANK) {
ctx.fillText('+1', this.x, this.y);
} else {
ctx.font = `${Math.floor(this.size * 0.6)}px Arial`;
ctx.fillText(visual.emoji, this.x, this.y);
}
}
/**
* Get bounding box for collision.
* @returns {{x: number, y: number, w: number, h: number}}
*/
getBounds() {
return {
x: this.x - this.halfSize,
y: this.y - this.halfSize,
w: this.size,
h: this.size,
};
}
/**
* Generate a random position on the map (avoiding terrain).
* @param {MapManager} mapManager
* @returns {{x: number, y: number}}
*/
static randomPosition(mapManager) {
let attempts = 0;
while (attempts < 50) {
const col = Math.floor(Math.random() * GRID_COLS);
const row = Math.floor(Math.random() * (GRID_ROWS - 2)) + 1; // avoid top/bottom rows
const terrain = mapManager.getTerrain(row, col);
// Only place on empty tiles
if (terrain === 0) { // TERRAIN.EMPTY
return {
x: MAP_OFFSET_X + col * TILE_SIZE + TILE_SIZE / 2,
y: MAP_OFFSET_Y + row * TILE_SIZE + TILE_SIZE / 2,
};
}
attempts++;
}
// Fallback: center of map
return {
x: MAP_OFFSET_X + MAP_WIDTH / 2,
y: MAP_OFFSET_Y + MAP_HEIGHT / 2,
};
}
/**
* Get a random power-up type based on level-adjusted probabilities.
* @param {number} levelNum - Current level number.
* @returns {string} POWERUP_TYPE value.
*/
static randomType(levelNum) {
// Base probabilities (weights)
const weights = {
[POWERUP_TYPE.STAR]: Math.max(10, 30 - levelNum), // decreases with level
[POWERUP_TYPE.CLOCK]: 15,
[POWERUP_TYPE.BOMB]: 10,
[POWERUP_TYPE.HELMET]: 15,
[POWERUP_TYPE.SHOVEL]: 10,
[POWERUP_TYPE.TANK]: 10,
};
const totalWeight = Object.values(weights).reduce((a, b) => a + b, 0);
let rand = Math.random() * totalWeight;
for (const [type, weight] of Object.entries(weights)) {
rand -= weight;
if (rand <= 0) return type;
}
return POWERUP_TYPE.STAR; // fallback
}
}
module.exports = PowerUp;
+375
View File
@@ -0,0 +1,375 @@
/**
* Tank.js
* Base class for all tanks (player and enemy).
* Handles position, direction, movement, rendering, and collision box.
*/
const {
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
MAP_WIDTH,
MAP_HEIGHT,
DIRECTION,
DIR_VECTORS,
} = require('../base/GameGlobal');
class Tank {
/**
* @param {object} config
* @param {number} config.x - Pixel X position (center).
* @param {number} config.y - Pixel Y position (center).
* @param {number} config.speed - Movement speed (pixels per frame at 60fps).
* @param {number} config.hp - Hit points.
* @param {string} config.color - Fill color.
* @param {number} config.size - Tank size in pixels.
* @param {number} [config.direction] - Initial direction.
*/
constructor(config) {
this.x = config.x;
this.y = config.y;
this.speed = config.speed || 2;
this.hp = config.hp || 1;
this.maxHp = config.hp || 1;
this.color = config.color || '#FFFFFF';
this.size = config.size || TILE_SIZE * 0.85;
this.direction = config.direction !== undefined ? config.direction : DIRECTION.UP;
this.alive = true;
this.visible = true;
// Half-size for collision calculations
this.halfSize = this.size / 2;
}
/**
* Move the tank in a direction.
* @param {number} dir - DIRECTION enum value.
* @param {number} dt - Delta time in seconds.
* @param {MapManager} mapManager - For collision checking.
* @returns {boolean} Whether the tank actually moved.
*/
move(dir, dt, mapManager) {
if (!this.alive) return false;
const prevDir = this.direction;
this.direction = dir;
// When changing direction, snap to nearest grid alignment first
// and do NOT advance forward this frame — classic Battle City behavior.
if (prevDir !== dir) {
this._snapToGrid(prevDir);
return false;
}
const vec = DIR_VECTORS[dir];
const moveAmount = this.speed * dt * 60; // normalize to 60fps
let newX = this.x + vec.dx * moveAmount;
let newY = this.y + vec.dy * moveAmount;
// Clamp to map boundaries instead of rejecting movement entirely.
// This allows the tank to slide along the edge smoothly.
const minX = MAP_OFFSET_X + this.halfSize;
const minY = MAP_OFFSET_Y + this.halfSize;
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize;
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize;
newX = Math.max(minX, Math.min(newX, maxX));
newY = Math.max(minY, Math.min(newY, maxY));
// If position didn't change after clamping, we're stuck at the boundary
if (newX === this.x && newY === this.y) {
return false;
}
// Calculate bounding box at clamped position
const left = newX - this.halfSize;
const top = newY - this.halfSize;
// Terrain collision check
if (mapManager && mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)) {
// Try to align to grid for smoother movement along walls
return this._tryAlignedMove(dir, dt, mapManager);
}
this.x = newX;
this.y = newY;
return true;
}
/**
* Snap the tank center to the nearest grid-cell center on the axis
* of the OLD direction. This prevents the tank from "drifting" when
* turning and ensures clean grid-aligned movement.
* @param {number} oldDir - The direction the tank was facing before turning.
* @private
*/
_snapToGrid(oldDir) {
const halfTile = TILE_SIZE / 2;
const minX = MAP_OFFSET_X + this.halfSize;
const minY = MAP_OFFSET_Y + this.halfSize;
const maxX = MAP_OFFSET_X + MAP_WIDTH - this.halfSize;
const maxY = MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize;
if (oldDir === DIRECTION.UP || oldDir === DIRECTION.DOWN) {
// Was moving vertically → snap Y to nearest grid-cell center
const rowExact = (this.y - MAP_OFFSET_Y - halfTile) / TILE_SIZE;
const nearestRow = Math.round(rowExact);
let alignedY = MAP_OFFSET_Y + nearestRow * TILE_SIZE + halfTile;
// Clamp to map bounds so snapping doesn't push tank outside
alignedY = Math.max(minY, Math.min(alignedY, maxY));
if (Math.abs(alignedY - this.y) < TILE_SIZE * 0.5) {
this.y = alignedY;
}
} else {
// Was moving horizontally → snap X to nearest grid-cell center
const colExact = (this.x - MAP_OFFSET_X - halfTile) / TILE_SIZE;
const nearestCol = Math.round(colExact);
let alignedX = MAP_OFFSET_X + nearestCol * TILE_SIZE + halfTile;
// Clamp to map bounds so snapping doesn't push tank outside
alignedX = Math.max(minX, Math.min(alignedX, maxX));
if (Math.abs(alignedX - this.x) < TILE_SIZE * 0.5) {
this.x = alignedX;
}
}
}
/**
* Try to move with grid alignment (helps navigate around corners).
* When blocked, find the nearest gap in the perpendicular axis and slide
* the tank towards it so the player can smoothly pass through openings
* between bricks — classic Battle City "snap-to-gap" behaviour.
* @private
*/
_tryAlignedMove(dir, dt, mapManager) {
const moveAmount = this.speed * dt * 60;
const vec = DIR_VECTORS[dir];
const halfTile = TILE_SIZE / 2;
if (dir === DIRECTION.UP || dir === DIRECTION.DOWN) {
// Moving vertically but blocked — try to slide horizontally into a gap
// Check two candidate column alignments (left-snap and right-snap)
const colExact = (this.x - MAP_OFFSET_X - halfTile) / TILE_SIZE;
const colLeft = Math.floor(colExact);
const colRight = Math.ceil(colExact);
const candidates = [];
for (const col of [colLeft, colRight]) {
const alignedX = MAP_OFFSET_X + col * TILE_SIZE + halfTile;
const diffX = alignedX - this.x;
// Only consider if the offset is within a comfortable snap threshold
if (Math.abs(diffX) < TILE_SIZE * 0.55) {
// Check whether moving in the desired direction would be clear at this aligned X
const testX = alignedX;
const testY = this.y + vec.dy * moveAmount;
const left = testX - this.halfSize;
const top = testY - this.halfSize;
if (
left >= MAP_OFFSET_X &&
top >= MAP_OFFSET_Y &&
left + this.size <= MAP_OFFSET_X + MAP_WIDTH &&
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT &&
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)
) {
candidates.push({ alignedX, diffX: Math.abs(diffX) });
}
}
}
if (candidates.length > 0) {
// Pick the closest gap
candidates.sort((a, b) => a.diffX - b.diffX);
const best = candidates[0];
const diffX = best.alignedX - this.x;
const slideAmount = Math.min(Math.abs(diffX), moveAmount);
this.x += Math.sign(diffX) * slideAmount;
// Clamp to map bounds after sliding
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize));
return false;
}
// No gap found — just do a basic grid-align slide
const gridCol = Math.round(colExact);
const alignedX = MAP_OFFSET_X + gridCol * TILE_SIZE + halfTile;
const diffX = alignedX - this.x;
if (Math.abs(diffX) < TILE_SIZE * 0.4) {
const slideAmount = Math.min(Math.abs(diffX), moveAmount);
this.x += Math.sign(diffX) * slideAmount;
// Clamp to map bounds after sliding
this.x = Math.max(MAP_OFFSET_X + this.halfSize, Math.min(this.x, MAP_OFFSET_X + MAP_WIDTH - this.halfSize));
}
} else {
// Moving horizontally but blocked — try to slide vertically into a gap
const rowExact = (this.y - MAP_OFFSET_Y - halfTile) / TILE_SIZE;
const rowUp = Math.floor(rowExact);
const rowDown = Math.ceil(rowExact);
const candidates = [];
for (const row of [rowUp, rowDown]) {
const alignedY = MAP_OFFSET_Y + row * TILE_SIZE + halfTile;
const diffY = alignedY - this.y;
if (Math.abs(diffY) < TILE_SIZE * 0.55) {
const testX = this.x + vec.dx * moveAmount;
const testY = alignedY;
const left = testX - this.halfSize;
const top = testY - this.halfSize;
if (
left >= MAP_OFFSET_X &&
top >= MAP_OFFSET_Y &&
left + this.size <= MAP_OFFSET_X + MAP_WIDTH &&
top + this.size <= MAP_OFFSET_Y + MAP_HEIGHT &&
!mapManager.rectCollidesWithTerrain(left, top, this.size, this.size)
) {
candidates.push({ alignedY, diffY: Math.abs(diffY) });
}
}
}
if (candidates.length > 0) {
candidates.sort((a, b) => a.diffY - b.diffY);
const best = candidates[0];
const diffY = best.alignedY - this.y;
const slideAmount = Math.min(Math.abs(diffY), moveAmount);
this.y += Math.sign(diffY) * slideAmount;
// Clamp to map bounds after sliding
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize));
return false;
}
// No gap found — basic grid-align slide
const gridRow = Math.round(rowExact);
const alignedY = MAP_OFFSET_Y + gridRow * TILE_SIZE + halfTile;
const diffY = alignedY - this.y;
if (Math.abs(diffY) < TILE_SIZE * 0.4) {
const slideAmount = Math.min(Math.abs(diffY), moveAmount);
this.y += Math.sign(diffY) * slideAmount;
// Clamp to map bounds after sliding
this.y = Math.max(MAP_OFFSET_Y + this.halfSize, Math.min(this.y, MAP_OFFSET_Y + MAP_HEIGHT - this.halfSize));
}
}
return false;
}
/**
* Take damage.
* @param {number} [amount=1]
* @returns {boolean} Whether the tank was destroyed.
*/
takeDamage(amount = 1) {
if (!this.alive) return false;
this.hp -= amount;
if (this.hp <= 0) {
this.hp = 0;
this.alive = false;
return true; // destroyed
}
return false;
}
/**
* Render the tank.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this.alive || !this.visible) return;
ctx.save();
ctx.translate(this.x, this.y);
// Rotate based on direction
const angles = {
[DIRECTION.UP]: 0,
[DIRECTION.DOWN]: Math.PI,
[DIRECTION.LEFT]: -Math.PI / 2,
[DIRECTION.RIGHT]: Math.PI / 2,
};
ctx.rotate(angles[this.direction]);
const hs = this.halfSize;
// Determine colors: use skin colors if this is a player tank with a skin
let bodyColor = this.color;
let turretColor = this._darkenColor(this.color, 0.3);
let trackColor = this._darkenColor(this.color, 0.4);
if (this._skinColors) {
bodyColor = this._skinColors.body || bodyColor;
turretColor = this._skinColors.turret || turretColor;
trackColor = this._skinColors.track || trackColor;
}
// Tank body
ctx.fillStyle = bodyColor;
ctx.fillRect(-hs, -hs, this.size, this.size);
// Tank turret (barrel)
const barrelW = this.size * 0.15;
const barrelH = this.size * 0.5;
ctx.fillStyle = turretColor;
ctx.fillRect(-barrelW / 2, -hs - barrelH, barrelW, barrelH);
// Tank body detail (center square)
const innerSize = this.size * 0.4;
ctx.fillStyle = this._darkenColor(bodyColor, 0.2);
ctx.fillRect(-innerSize / 2, -innerSize / 2, innerSize, innerSize);
// Tracks
const trackW = this.size * 0.12;
ctx.fillStyle = trackColor;
ctx.fillRect(-hs, -hs, trackW, this.size);
ctx.fillRect(hs - trackW, -hs, trackW, this.size);
ctx.restore();
}
/**
* Get the axis-aligned bounding box.
* @returns {{x: number, y: number, w: number, h: number}}
*/
getBounds() {
return {
x: this.x - this.halfSize,
y: this.y - this.halfSize,
w: this.size,
h: this.size,
};
}
/**
* Check collision with another tank.
* @param {Tank} other
* @returns {boolean}
*/
collidesWith(other) {
if (!this.alive || !other.alive) return false;
const a = this.getBounds();
const b = other.getBounds();
return (
a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y
);
}
/**
* Darken a hex color.
* @param {string} hex
* @param {number} factor - 0 to 1
* @returns {string}
*/
_darkenColor(hex, factor) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const dr = Math.floor(r * (1 - factor));
const dg = Math.floor(g * (1 - factor));
const db = Math.floor(b * (1 - factor));
return `rgb(${dr},${dg},${db})`;
}
}
module.exports = Tank;
+91
View File
@@ -0,0 +1,91 @@
/**
* I18n.js
* Internationalization module for Tank Adventure.
* Auto-detects language from WeChat system info.
* Supports {variable} placeholder interpolation.
*/
const zhLang = require('./zh');
const enLang = require('./en');
// ============================================================
// Language Detection
// ============================================================
let _currentLang = 'en'; // default fallback
try {
const sysInfo = wx.getSystemInfoSync();
const lang = (sysInfo.language || '').toLowerCase();
// zh_CN, zh_TW, zh_HK, etc.
if (lang.startsWith('zh')) {
_currentLang = 'zh';
}
} catch (e) {
// Fallback to English if wx API is unavailable
_currentLang = 'en';
}
const _langPacks = {
zh: zhLang,
en: enLang,
};
// ============================================================
// Translation Function
// ============================================================
/**
* Get translated text by key.
* Supports {variable} placeholder interpolation.
*
* @param {string} key - The translation key, e.g. 'menu.title'
* @param {Object} [params] - Optional parameters for interpolation
* @returns {string} The translated text
*
* @example
* t('menu.title') // => '坦克探险' or 'Tank Adventure'
* t('pvp.hp', { count: 3 }) // => '生命 x3' or 'HP x3'
*/
function t(key, params) {
// Try current language first
let text = _langPacks[_currentLang] && _langPacks[_currentLang][key];
// Fallback to English
if (text === undefined && _currentLang !== 'en') {
text = _langPacks.en[key];
}
// Fallback to key itself
if (text === undefined) {
return key;
}
// Interpolate {variable} placeholders
if (params) {
text = text.replace(/\{(\w+)\}/g, (match, name) => {
return params[name] !== undefined ? String(params[name]) : match;
});
}
return text;
}
/**
* Get the current language code.
* @returns {string} 'zh' or 'en'
*/
function getLang() {
return _currentLang;
}
/**
* Set the language manually (for testing or future settings).
* @param {string} lang - 'zh' or 'en'
*/
function setLang(lang) {
if (_langPacks[lang]) {
_currentLang = lang;
}
}
module.exports = { t, getLang, setLang };
+272
View File
@@ -0,0 +1,272 @@
/**
* en.js
* English language pack for Tank Adventure.
*/
module.exports = {
// ============================================================
// Common
// ============================================================
'common.back': '← Back',
'common.joinBtn': 'Join',
'common.cannotConnect': 'Cannot connect to server',
'common.connectFailed': 'Connection failed',
'common.disconnected': 'Disconnected from server',
'common.paused': 'PAUSED',
'common.tapContinue': 'Tap to continue',
'common.kicked': 'You have been kicked from the team',
// ============================================================
// Menu Scene
// ============================================================
'menu.title': 'Tank Adventure',
'menu.subtitle': 'TANK WAR',
'menu.classic': 'Classic',
'menu.endless': 'Endless',
'menu.pvp': 'PVP',
'menu.team3v3': '3v3 Battle',
'menu.shop': 'Shop',
'menu.ranking': 'Ranking',
'menu.settings': 'Settings',
// ============================================================
// Room Scene (PVP)
// ============================================================
'room.title': 'PVP Battle',
'room.idleHint': 'Create a room or join with a code',
'room.create': 'Create Room',
'room.join': 'Join Room',
'room.connecting': 'Connecting{dots}',
'room.roomCode': 'Room Code:',
'room.waiting': 'Waiting for opponent{dots}',
'room.shareHint': 'Share the room code with your friend',
'room.inputCode': 'Enter Room Code:',
'room.opponentFound': 'Opponent found!',
'room.starting': 'Game starting...',
'room.tapBack': 'Tap anywhere to go back',
// ============================================================
// Team Room Scene (3v3)
// ============================================================
'teamRoom.title': '3v3 Team Battle',
'teamRoom.chooseMode': 'Choose how to play',
'teamRoom.createTeam': '🎮 Create Team',
'teamRoom.soloMatch': '⚡ Quick Match',
'teamRoom.teamId': 'Team: {id}',
'teamRoom.leader': 'Leader',
'teamRoom.ready': '✓ Ready',
'teamRoom.notReady': 'Not Ready',
'teamRoom.emptySlot': 'Empty',
'teamRoom.invite': '📨 Invite',
'teamRoom.startMatch': '🔍 Start Match',
'teamRoom.disband': 'Disband',
'teamRoom.readyBtn': '✓ Ready',
'teamRoom.cancelReady': 'Cancel Ready',
'teamRoom.leaveTeam': 'Leave Team',
'teamRoom.matching': 'Matching{dots}',
'teamRoom.waitTime': 'Waited {seconds}s',
'teamRoom.cancelMatch': 'Cancel Match',
'teamRoom.matchFound': 'Match found!',
'teamRoom.enterBattle': 'Entering battle...',
'teamRoom.tapBack': 'Tap anywhere to go back',
'teamRoom.shareTitle': 'Tank 3v3, join the battle!',
'teamRoom.joining': 'Joining room',
// ============================================================
// PVP Game Scene
// ============================================================
'pvp.playerLabel': 'P{slot} (You)',
'pvp.hp': 'HP x{count}',
'pvp.kills': 'Kills: {count}',
'pvp.killDeath': 'K:{kills} D:{deaths}',
'pvp.respawn': 'Respawning in {seconds}s',
'pvp.youWin': 'YOU WIN!',
'pvp.draw': 'DRAW',
'pvp.youLose': 'YOU LOSE',
'pvp.baseHpSummary': 'P1: {hp1} HP | P2: {hp2} HP',
// ============================================================
// Team Game Scene (3v3)
// ============================================================
'team.teamA': 'Team A',
'team.teamB': 'Team B',
'team.myTeam': 'You: {team} Team',
'team.killDeath': 'K:{kills} D:{deaths}',
'team.respawn': 'Respawning in {seconds}s',
'team.victory': 'VICTORY!',
'team.defeat': 'DEFEAT',
'team.baseHpSummary': 'Team A: {hpA} HP | Team B: {hpB} HP',
'team.disconnectTitle': '⚠ Connection Lost',
'team.reconnecting': 'Reconnecting{dots} ({attempts}/{max})',
'team.reconnectHint': 'Please wait, your tank will be controlled by AI',
// ============================================================
// PVP Result Scene
// ============================================================
'pvpResult.title': 'MATCH RESULT',
'pvpResult.victory': '🏆 VICTORY!',
'pvpResult.draw': '⚔️ DRAW',
'pvpResult.defeat': '😵 DEFEAT',
'pvpResult.kills': 'Kills',
'pvpResult.deaths': 'Deaths',
'pvpResult.lives': 'Lives',
'pvpResult.baseDmg': 'Base DMG',
'pvpResult.p1BaseHp': 'P1: {hp} HP',
'pvpResult.p2BaseHp': 'P2: {hp} HP',
'pvpResult.baseDestroyed': 'Base Destroyed',
'pvpResult.disconnectedReason': 'Disconnected',
'pvpResult.duration': 'Match duration: {time}',
'pvpResult.timeRemaining': 'Time remaining: {time}',
'pvpResult.rematch': 'Rematch',
'pvpResult.backMenu': 'Back to Menu',
// ============================================================
// Team Result Scene (3v3)
// ============================================================
'teamResult.title': '3v3 MATCH RESULT',
'teamResult.victory': '🏆 VICTORY!',
'teamResult.defeat': '😵 DEFEAT',
'teamResult.teamAHp': 'Team A: {hp} HP',
'teamResult.teamBHp': 'Team B: {hp} HP',
'teamResult.baseDestroyed': 'Base Destroyed',
'teamResult.disconnectedReason': 'Disconnected',
'teamResult.teamAHeader': 'Team A',
'teamResult.teamBHeader': 'Team B',
'teamResult.myTeamSuffix': ' (You)',
'teamResult.player': 'Player',
'teamResult.k': 'K',
'teamResult.d': 'D',
'teamResult.a': 'A',
'teamResult.dmg': 'DMG',
'teamResult.bot': '🤖 Bot',
'teamResult.duration': 'Match duration: {time}',
'teamResult.mvp': '⭐ MVP: {name} ({kills} kills)',
'teamResult.rankUp': '📈 Rank +{points}',
'teamResult.mvpBonus': '(MVP bonus +5)',
'teamResult.rankDown': '📉 Rank -{points}',
'teamResult.rematch': 'Rematch',
'teamResult.rematchWaiting': 'Waiting({ready}/{total})',
'teamResult.backMenu': 'Back to Menu',
// ============================================================
// Game Scene (Classic/Endless)
// ============================================================
'game.level': 'Level {level}',
'game.hp': 'HP x{count}',
'game.fireLevel': 'LV{level}',
'game.enemies': 'Enemies: {count}',
'game.score': '{score}pts',
'game.gameOver': 'GAME OVER',
'game.stageClear': 'STAGE CLEAR!',
// ============================================================
// Result Scene
// ============================================================
'result.victory': '🎉 STAGE CLEAR!',
'result.defeat': '😵 GAME OVER',
'result.level': 'Level {level}',
'result.killStats': 'Kill Statistics',
'result.tankNormal': 'Normal',
'result.tankFast': 'Fast',
'result.tankArmor': 'Armor',
'result.tankBoss': 'BOSS',
'result.totalLabel': 'Total',
'result.rowKills': 'Kills',
'result.rowScore': 'Score',
'result.totalScore': 'Total: {score}',
'result.time': 'Time: {minutes}m{seconds}s',
'result.baseAlive': 'Base: ✅ Intact',
'result.baseDestroyed': 'Base: ❌ Destroyed',
'result.newRecord': '🎊 New Record!',
'result.doubled': '2x!',
'result.share': '📤 Share Challenge',
'result.adDouble': '🎬 Watch Ad for 2x Score',
'result.nextLevel': 'Next Level →',
'result.retry': 'Retry',
'result.backMenu': 'Back to Menu',
// ============================================================
// Ranking Scene
// ============================================================
'ranking.title': '🏆 Ranking',
'ranking.personalRecord': '— Personal Records —',
'ranking.classicHigh': 'Classic Mode High Score',
'ranking.endlessHigh': 'Endless Mode High Score',
'ranking.highestLevel': 'Highest Level Cleared',
'ranking.levelSuffix': 'Lv',
'ranking.scoreSuffix': 'pts',
'ranking.friendHint': 'Friend ranking requires WeChat Open Data Domain',
// ============================================================
// Settings Scene
// ============================================================
'settings.title': 'Settings',
'settings.sound': 'Sound',
'settings.music': 'Music',
'settings.vibration': 'Vibration',
// ============================================================
// Shop Scene (Simplified)
// ============================================================
'shop.title': 'Shop',
'shop.goldBalance': 'Gold Balance',
'shop.adFree': 'Remove Ads',
'shop.adFreeDesc': 'Permanently remove interstitial ads',
'shop.adFreeOwned': 'Owned',
'shop.goldPack': 'Gold Pack',
'shop.goldPackDesc': '1000 Gold',
'shop.newcomerPack': 'Newcomer Pack',
'shop.newcomerPackDesc': '500 Gold',
'shop.newcomerExpired': 'Expired',
'shop.buy': 'Buy',
'shop.purchased': 'Purchased',
// ============================================================
// Ad System
// ============================================================
'ad.reviveTitle': 'Revive Chance',
'ad.reviveDesc': 'Choose how to revive and continue',
'ad.watchAd': '📺 Watch Ad (Free)',
'ad.goldRevive': '🪙 Gold Revive (200)',
'ad.giveUp': 'Give Up',
'ad.doubleReward': '🎬 Watch Ad for 2x Reward',
'ad.unavailable': 'Ad temporarily unavailable',
'ad.dailyLimitReached': 'Daily ad recovery limit reached',
// ============================================================
// Currency (Simplified - Gold only)
// ============================================================
'currency.gold': 'Gold',
'currency.insufficient': 'Insufficient Gold',
'currency.full': 'Gold is full',
// ============================================================
// IAP Products (Simplified)
// ============================================================
'iap.adFree': 'Remove Ads (¥18 Permanent)',
'iap.goldPack': 'Gold Pack (¥6)',
'iap.newcomerPack': 'Newcomer Pack (¥1)',
// ============================================================
// Buff System
// ============================================================
'buff.title': 'Pre-Game Buffs',
'buff.shield': '🛡️ Shield',
'buff.shieldDesc': 'Start with a shield layer',
'buff.doubleFire': '🔥 Double Fire',
'buff.doubleFireDesc': '2x bullet power for 10s',
'buff.skip': 'Skip →',
'buff.start': 'Start Game',
'buff.purchased': 'Purchased',
'buff.goldInsufficient': 'Insufficient Gold',
// ============================================================
// Daily Gold
// ============================================================
'dailyGold.btn': '🪙 Get Gold',
'dailyGold.remaining': '{remaining}/3',
'dailyGold.exhausted': 'Come back tomorrow',
'dailyGold.reward': '+100 Gold!',
};
+272
View File
@@ -0,0 +1,272 @@
/**
* zh.js
* Chinese language pack for Tank Adventure.
*/
module.exports = {
// ============================================================
// Common
// ============================================================
'common.back': '← 返回',
'common.joinBtn': '加入',
'common.cannotConnect': '无法连接服务器',
'common.connectFailed': '连接失败',
'common.disconnected': '与服务器断开连接',
'common.paused': '暂停',
'common.tapContinue': '点击继续',
'common.kicked': '你已被踢出队伍',
// ============================================================
// Menu Scene
// ============================================================
'menu.title': '坦克探险',
'menu.subtitle': '经典坦克对战',
'menu.classic': '经典模式',
'menu.endless': '无尽模式',
'menu.pvp': '双人对战',
'menu.team3v3': '3v3 对战',
'menu.shop': '商店',
'menu.ranking': '排行榜',
'menu.settings': '设置',
// ============================================================
// Room Scene (PVP)
// ============================================================
'room.title': '双人对战',
'room.idleHint': '创建房间或输入房间号加入',
'room.create': '创建房间',
'room.join': '加入房间',
'room.connecting': '连接中{dots}',
'room.roomCode': '房间号:',
'room.waiting': '等待对手加入{dots}',
'room.shareHint': '将房间号分享给好友',
'room.inputCode': '输入房间号:',
'room.opponentFound': '对手已找到!',
'room.starting': '即将开始...',
'room.tapBack': '点击任意位置返回',
// ============================================================
// Team Room Scene (3v3)
// ============================================================
'teamRoom.title': '3v3 团队对战',
'teamRoom.chooseMode': '选择游戏方式',
'teamRoom.createTeam': '🎮 组队开黑',
'teamRoom.soloMatch': '⚡ 快速匹配',
'teamRoom.teamId': '队伍:{id}',
'teamRoom.leader': '队长',
'teamRoom.ready': '✓ 已准备',
'teamRoom.notReady': '未准备',
'teamRoom.emptySlot': '空位',
'teamRoom.invite': '📨 邀请好友',
'teamRoom.startMatch': '🔍 开始匹配',
'teamRoom.disband': '解散队伍',
'teamRoom.readyBtn': '✓ 准备',
'teamRoom.cancelReady': '取消准备',
'teamRoom.leaveTeam': '退出队伍',
'teamRoom.matching': '匹配中{dots}',
'teamRoom.waitTime': '已等待 {seconds} 秒',
'teamRoom.cancelMatch': '取消匹配',
'teamRoom.matchFound': '对手已找到!',
'teamRoom.enterBattle': '即将进入战斗...',
'teamRoom.tapBack': '点击任意位置返回',
'teamRoom.shareTitle': '坦克3v3,速来开黑!',
'teamRoom.joining': '正在加入房间',
// ============================================================
// PVP Game Scene
// ============================================================
'pvp.playerLabel': 'P{slot} (我方)',
'pvp.hp': '生命 x{count}',
'pvp.kills': '击杀:{count}',
'pvp.killDeath': '杀:{kills} 亡:{deaths}',
'pvp.respawn': '{seconds}秒后重生',
'pvp.youWin': '你赢了!',
'pvp.draw': '平局',
'pvp.youLose': '你输了',
'pvp.baseHpSummary': 'P1{hp1} 生命 | P2{hp2} 生命',
// ============================================================
// Team Game Scene (3v3)
// ============================================================
'team.teamA': 'A队',
'team.teamB': 'B队',
'team.myTeam': '我方:{team}队',
'team.killDeath': '杀:{kills} 亡:{deaths}',
'team.respawn': '{seconds}秒后重生',
'team.victory': '胜利!',
'team.defeat': '失败',
'team.baseHpSummary': 'A队:{hpA} 生命 | B队:{hpB} 生命',
'team.disconnectTitle': '⚠ 连接断开',
'team.reconnecting': '重连中{dots} ({attempts}/{max})',
'team.reconnectHint': '请稍候,您的坦克将由AI代管',
// ============================================================
// PVP Result Scene
// ============================================================
'pvpResult.title': '对战结果',
'pvpResult.victory': '🏆 胜利!',
'pvpResult.draw': '⚔️ 平局',
'pvpResult.defeat': '😵 失败',
'pvpResult.kills': '击杀',
'pvpResult.deaths': '死亡',
'pvpResult.lives': '生命',
'pvpResult.baseDmg': '阵地伤害',
'pvpResult.p1BaseHp': 'P1{hp} 生命',
'pvpResult.p2BaseHp': 'P2{hp} 生命',
'pvpResult.baseDestroyed': '基地被摧毁',
'pvpResult.disconnectedReason': '断线',
'pvpResult.duration': '对战时长:{time}',
'pvpResult.timeRemaining': '剩余时间:{time}',
'pvpResult.rematch': '再来一局',
'pvpResult.backMenu': '返回菜单',
// ============================================================
// Team Result Scene (3v3)
// ============================================================
'teamResult.title': '3v3 对战结果',
'teamResult.victory': '🏆 胜利!',
'teamResult.defeat': '😵 失败',
'teamResult.teamAHp': 'A队:{hp} 生命',
'teamResult.teamBHp': 'B队:{hp} 生命',
'teamResult.baseDestroyed': '基地被摧毁',
'teamResult.disconnectedReason': '断线',
'teamResult.teamAHeader': 'A队',
'teamResult.teamBHeader': 'B队',
'teamResult.myTeamSuffix': ' (我方)',
'teamResult.player': '玩家',
'teamResult.k': '杀',
'teamResult.d': '亡',
'teamResult.a': '助',
'teamResult.dmg': '伤害',
'teamResult.bot': '🤖 机器人',
'teamResult.duration': '对战时长:{time}',
'teamResult.mvp': '⭐ MVP{name}{kills} 击杀)',
'teamResult.rankUp': '📈 积分 +{points}',
'teamResult.mvpBonus': 'MVP加成 +5',
'teamResult.rankDown': '📉 积分 -{points}',
'teamResult.rematch': '再来一局',
'teamResult.rematchWaiting': '等待中({ready}/{total})',
'teamResult.backMenu': '返回菜单',
// ============================================================
// Game Scene (Classic/Endless)
// ============================================================
'game.level': '第 {level} 关',
'game.hp': '生命 x{count}',
'game.fireLevel': 'LV{level}',
'game.enemies': '敌人: {count}',
'game.score': '{score}分',
'game.gameOver': '游戏结束',
'game.stageClear': '关卡通过!',
// ============================================================
// Result Scene
// ============================================================
'result.victory': '🎉 关卡通过!',
'result.defeat': '😵 游戏结束',
'result.level': '第 {level} 关',
'result.killStats': '击杀统计',
'result.tankNormal': '普通',
'result.tankFast': '快速',
'result.tankArmor': '重甲',
'result.tankBoss': 'BOSS',
'result.totalLabel': '汇总',
'result.rowKills': '击杀',
'result.rowScore': '得分',
'result.totalScore': '总分: {score}',
'result.time': '用时: {minutes}分{seconds}秒',
'result.baseAlive': '基地: ✅ 完好',
'result.baseDestroyed': '基地: ❌ 被毁',
'result.newRecord': '🎊 新纪录!',
'result.doubled': '双倍!',
'result.share': '📤 分享挑战书',
'result.adDouble': '🎬 看广告双倍得分',
'result.nextLevel': '下一关 →',
'result.retry': '重新开始',
'result.backMenu': '返回主菜单',
// ============================================================
// Ranking Scene
// ============================================================
'ranking.title': '🏆 排行榜',
'ranking.personalRecord': '— 个人记录 —',
'ranking.classicHigh': '经典模式最高分',
'ranking.endlessHigh': '无尽模式最高分',
'ranking.highestLevel': '最高通关关卡',
'ranking.levelSuffix': '关',
'ranking.scoreSuffix': '分',
'ranking.friendHint': '好友排行榜需要微信开放数据域支持',
// ============================================================
// Settings Scene
// ============================================================
'settings.title': '设置',
'settings.sound': '音效',
'settings.music': '音乐',
'settings.vibration': '振动',
// ============================================================
// Shop Scene (Simplified)
// ============================================================
'shop.title': '商店',
'shop.goldBalance': '金币余额',
'shop.adFree': '去广告特权',
'shop.adFreeDesc': '永久移除插屏广告',
'shop.adFreeOwned': '已拥有',
'shop.goldPack': '金币包',
'shop.goldPackDesc': '1000 金币',
'shop.newcomerPack': '新手礼包',
'shop.newcomerPackDesc': '500 金币',
'shop.newcomerExpired': '已过期',
'shop.buy': '购买',
'shop.purchased': '已购买',
// ============================================================
// Ad System
// ============================================================
'ad.reviveTitle': '复活机会',
'ad.reviveDesc': '选择复活方式继续游戏',
'ad.watchAd': '📺 观看广告(免费)',
'ad.goldRevive': '🪙 金币复活(200',
'ad.giveUp': '放弃',
'ad.doubleReward': '🎬 看广告双倍奖励',
'ad.unavailable': '广告暂时不可用',
'ad.dailyLimitReached': '今日广告恢复次数已用完',
// ============================================================
// Currency (Simplified - Gold only)
// ============================================================
'currency.gold': '金币',
'currency.insufficient': '金币不足',
'currency.full': '金币已满',
// ============================================================
// IAP Products (Simplified)
// ============================================================
'iap.adFree': '去广告特权(¥18 永久)',
'iap.goldPack': '金币包(¥6',
'iap.newcomerPack': '新手礼包(¥1',
// ============================================================
// Buff System
// ============================================================
'buff.title': '局前增益',
'buff.shield': '🛡️ 护盾',
'buff.shieldDesc': '开局自带一层护盾',
'buff.doubleFire': '🔥 双倍火力',
'buff.doubleFireDesc': '开局10秒子弹威力翻倍',
'buff.skip': '跳过 →',
'buff.start': '开始游戏',
'buff.purchased': '已购买',
'buff.goldInsufficient': '金币不足',
// ============================================================
// Daily Gold
// ============================================================
'dailyGold.btn': '🪙 领金币',
'dailyGold.remaining': '{remaining}/3',
'dailyGold.exhausted': '明日再来',
'dailyGold.reward': '+100 金币!',
};
+447
View File
@@ -0,0 +1,447 @@
/**
* AdManager.js
* Manages WeChat mini game ads: rewarded video and interstitial.
* Supports scene-based ad triggering with per-scene cooldowns,
* daily limits, preloading, and frequency control.
*/
/**
* Ad scene types for rewarded video ads.
* Each scene has independent cooldown and optional daily limits.
*/
const AD_SCENE = {
REVIVE: 'REVIVE', // Revive after death
DOUBLE_REWARD: 'DOUBLE_REWARD', // Double settlement rewards
DAILY_GOLD: 'DAILY_GOLD', // Daily gold reward from main menu
};
/** Cooldown duration per scene in milliseconds (15 minutes). */
const SCENE_COOLDOWN_MS = 15 * 60 * 1000;
/** Daily limits for specific scenes. */
const SCENE_DAILY_LIMITS = {
[AD_SCENE.DAILY_GOLD]: 3,
};
class AdManager {
constructor() {
/** @type {RewardedVideoAd|null} */
this._rewardedVideo = null;
/** @type {InterstitialAd|null} */
this._interstitial = null;
// Interstitial frequency control: show every N games since last show
this._gamesSinceLastInterstitial = 0;
this._interstitialFrequency = 3;
// Ad unit IDs (replace with real IDs in production)
this._rewardedVideoId = 'adunit-reward-placeholder';
this._interstitialId = 'adunit-interstitial-placeholder';
// State
this._rewardedVideoReady = false;
this._interstitialReady = false;
this._adFreeEnabled = false; // purchased ad-free
// Callback for rewarded video completion
this._rewardCallback = null;
// Scene cooldown tracking: Map<sceneType, lastShowTimestamp>
this._sceneCooldowns = new Map();
// Daily scene count tracking: Map<sceneType, { date: string, count: number }>
this._sceneDailyCounts = new Map();
// Skip ad initialization if using placeholder IDs (dev environment)
this._isDevMode = this._rewardedVideoId.includes('placeholder') ||
this._interstitialId.includes('placeholder');
if (!this._isDevMode) {
this._init();
} else {
console.log('[AdManager] Dev mode: skipping ad initialization (placeholder IDs)');
}
// Restore daily counts from storage
this._restoreDailyCounts();
}
/**
* Initialize ad instances.
* @private
*/
_init() {
// Check if ad-free was purchased
try {
if (GameGlobal.storageManager) {
this._adFreeEnabled = GameGlobal.storageManager.hasPurchased('ad_free');
}
} catch (e) {}
this._createRewardedVideo();
this._createInterstitial();
}
/**
* Create rewarded video ad instance.
* @private
*/
_createRewardedVideo() {
try {
if (typeof wx === 'undefined' || typeof wx.createRewardedVideoAd !== 'function') return;
this._rewardedVideo = wx.createRewardedVideoAd({
adUnitId: this._rewardedVideoId,
});
this._rewardedVideo.onLoad(() => {
this._rewardedVideoReady = true;
console.log('[AdManager] Rewarded video loaded');
});
this._rewardedVideo.onError((err) => {
this._rewardedVideoReady = false;
console.warn('[AdManager] Rewarded video error:', err);
});
this._rewardedVideo.onClose((res) => {
// Dispatch reward instantly on ad close
if (res && res.isEnded) {
if (this._rewardCallback) {
this._rewardCallback(true);
this._rewardCallback = null;
}
} else {
// User closed early
if (this._rewardCallback) {
this._rewardCallback(false);
this._rewardCallback = null;
}
}
});
} catch (e) {
console.warn('[AdManager] Failed to create rewarded video:', e);
}
}
/**
* Create interstitial ad instance.
* @private
*/
_createInterstitial() {
if (this._adFreeEnabled) return;
try {
if (typeof wx === 'undefined' || typeof wx.createInterstitialAd !== 'function') return;
this._interstitial = wx.createInterstitialAd({
adUnitId: this._interstitialId,
});
this._interstitial.onLoad(() => {
this._interstitialReady = true;
});
this._interstitial.onError((err) => {
this._interstitialReady = false;
console.warn('[AdManager] Interstitial error:', err);
});
this._interstitial.onClose(() => {
this._interstitialReady = false;
});
} catch (e) {
console.warn('[AdManager] Failed to create interstitial:', e);
}
}
// ============================================================
// Scene Cooldown & Daily Limit Helpers
// ============================================================
/**
* Get today's date string (YYYY-MM-DD) for daily tracking.
* @returns {string}
* @private
*/
_getTodayKey() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/**
* Restore daily ad counts from StorageManager.
* @private
*/
_restoreDailyCounts() {
try {
if (GameGlobal && GameGlobal.storageManager) {
const saved = GameGlobal.storageManager.get('ad_daily_counts', null);
if (saved && typeof saved === 'object') {
const today = this._getTodayKey();
for (const [scene, data] of Object.entries(saved)) {
if (data.date === today) {
this._sceneDailyCounts.set(scene, { date: data.date, count: data.count });
}
// Stale dates are discarded
}
}
}
} catch (e) {}
}
/**
* Persist daily ad counts to StorageManager.
* @private
*/
_saveDailyCounts() {
try {
if (GameGlobal && GameGlobal.storageManager) {
const obj = {};
for (const [scene, data] of this._sceneDailyCounts.entries()) {
obj[scene] = data;
}
GameGlobal.storageManager.set('ad_daily_counts', obj);
}
} catch (e) {}
}
/**
* Get the daily count for a scene.
* @param {string} sceneType
* @returns {number}
* @private
*/
_getDailyCount(sceneType) {
const today = this._getTodayKey();
const entry = this._sceneDailyCounts.get(sceneType);
if (entry && entry.date === today) {
return entry.count;
}
return 0;
}
/**
* Increment the daily count for a scene.
* @param {string} sceneType
* @private
*/
_incrementDailyCount(sceneType) {
const today = this._getTodayKey();
const entry = this._sceneDailyCounts.get(sceneType);
if (entry && entry.date === today) {
entry.count++;
} else {
this._sceneDailyCounts.set(sceneType, { date: today, count: 1 });
}
this._saveDailyCounts();
}
/**
* Check if a scene ad can be shown (cooldown + daily limit).
* @param {string} sceneType - One of AD_SCENE values.
* @returns {boolean}
*/
canShowScene(sceneType) {
// Check cooldown
const lastShow = this._sceneCooldowns.get(sceneType);
if (lastShow && (Date.now() - lastShow < SCENE_COOLDOWN_MS)) {
console.log(`[AdManager] Scene ${sceneType} is in cooldown`);
return false;
}
// Check daily limit
const limit = SCENE_DAILY_LIMITS[sceneType];
if (limit !== undefined) {
const count = this._getDailyCount(sceneType);
if (count >= limit) {
console.log(`[AdManager] Scene ${sceneType} daily limit reached (${count}/${limit})`);
return false;
}
}
return true;
}
/**
* Get remaining daily count for a scene.
* @param {string} sceneType
* @returns {number} Remaining uses, or Infinity if no limit.
*/
getRemainingDailyCount(sceneType) {
const limit = SCENE_DAILY_LIMITS[sceneType];
if (limit === undefined) return Infinity;
return Math.max(0, limit - this._getDailyCount(sceneType));
}
// ============================================================
// Public API
// ============================================================
/**
* Preload the rewarded video ad (call during level loading).
* Ensures the ad is ready when needed, reducing wait time.
*/
preloadRewardedVideo() {
if (!this._rewardedVideo) return;
if (this._rewardedVideoReady) return; // Already loaded
try {
this._rewardedVideo.load().then(() => {
console.log('[AdManager] Rewarded video preloaded');
}).catch((err) => {
console.warn('[AdManager] Rewarded video preload failed:', err);
});
} catch (e) {}
}
/**
* Show a rewarded video ad for a specific scene.
* Checks cooldown and daily limits before showing.
* @param {string} sceneType - One of AD_SCENE values.
* @param {Function} callback - Called with (completed: boolean) when ad closes.
* @returns {boolean} Whether the ad was shown.
*/
showRewardedVideoForScene(sceneType, callback) {
if (!this.canShowScene(sceneType)) {
if (callback) callback(false);
return false;
}
const wrappedCallback = (completed) => {
if (completed) {
// Record cooldown timestamp
this._sceneCooldowns.set(sceneType, Date.now());
// Increment daily count
this._incrementDailyCount(sceneType);
}
if (callback) callback(completed);
};
return this.showRewardedVideo(wrappedCallback);
}
/**
* Show a rewarded video ad (low-level, no scene tracking).
* @param {Function} callback - Called with (completed: boolean) when ad closes.
* @returns {boolean} Whether the ad was shown (false if not ready).
*/
showRewardedVideo(callback) {
this._rewardCallback = callback;
if (!this._rewardedVideo) {
// Ad not available, give fallback
console.warn('[AdManager] Rewarded video not available');
if (callback) callback(false);
return false;
}
this._rewardedVideo.show().catch(() => {
// Try to reload and show again
this._rewardedVideo.load().then(() => {
this._rewardedVideo.show().catch(() => {
console.warn('[AdManager] Failed to show rewarded video');
if (callback) callback(false);
this._rewardCallback = null;
});
}).catch(() => {
if (callback) callback(false);
this._rewardCallback = null;
});
});
return true;
}
/**
* Show an interstitial ad (respects frequency control and ad-free purchase).
* Uses "games since last show" logic: shows after every N games.
*/
showInterstitial() {
if (this._adFreeEnabled) return;
this._gamesSinceLastInterstitial++;
if (this._gamesSinceLastInterstitial < this._interstitialFrequency) return;
if (!this._interstitial || !this._interstitialReady) return;
try {
this._interstitial.show().then(() => {
// Reset counter on successful show
this._gamesSinceLastInterstitial = 0;
}).catch(() => {
// Silently skip on failure, don't block player flow
console.warn('[AdManager] Failed to show interstitial, skipping');
});
} catch (e) {
// Silently skip
}
}
/**
* Show a daily gold reward ad.
* Convenience method for the DAILY_GOLD scene.
* On completion, emits 'daily_gold_reward' event and adds 100 gold.
* @param {Function} [callback] - Optional callback with (completed: boolean).
* @returns {boolean} Whether the ad was shown.
*/
showDailyGoldAd(callback) {
return this.showRewardedVideoForScene(AD_SCENE.DAILY_GOLD, (completed) => {
if (completed) {
// Award 100 gold
if (GameGlobal && GameGlobal.currencyManager) {
GameGlobal.currencyManager.addGold(100);
}
// Emit event for UI update
try {
if (GameGlobal && GameGlobal.eventBus) {
GameGlobal.eventBus.emit('daily_gold_reward', { amount: 100 });
}
} catch (e) {}
}
if (callback) callback(completed);
});
}
/**
* Get remaining daily gold ad claims for today.
* @returns {number}
*/
getDailyGoldRemaining() {
return this.getRemainingDailyCount(AD_SCENE.DAILY_GOLD);
}
/**
* Record that a game was played (for interstitial frequency).
*/
recordGamePlayed() {
// No-op: counting is now done inside showInterstitial()
// Kept for backward compatibility
}
/**
* Enable ad-free mode (after purchase).
*/
enableAdFree() {
this._adFreeEnabled = true;
if (GameGlobal && GameGlobal.storageManager) {
GameGlobal.storageManager.recordPurchase('ad_free');
}
}
/** Whether rewarded video is ready. */
get rewardedVideoReady() {
return this._rewardedVideoReady;
}
/** Whether ad-free mode is enabled. */
get adFreeEnabled() {
return this._adFreeEnabled;
}
}
// Export both the class and the scene enum
AdManager.AD_SCENE = AD_SCENE;
module.exports = AdManager;
+230
View File
@@ -0,0 +1,230 @@
/**
* AudioManager.js
* Manages game sound effects using wx.createWebAudioContext for programmatic synthesis.
* No external audio files needed — all sounds are generated via PCM buffers.
*/
class AudioManager {
constructor() {
this._soundEnabled = true;
this._musicEnabled = true;
/** @type {AudioContext|null} WebAudio context */
this._audioCtx = null;
// Cached audio buffers for each sound
/** @type {Map<string, AudioBuffer>} */
this._buffers = new Map();
this._initialized = false;
// Listen for settings changes
if (typeof GameGlobal !== 'undefined' && GameGlobal.eventBus) {
GameGlobal.eventBus.on('settings:changed', (settings) => {
this._soundEnabled = settings.soundEnabled;
this._musicEnabled = settings.musicEnabled;
});
}
}
/**
* Initialize WebAudio context and generate all sound buffers.
* Must be called after user interaction (touch) on some platforms.
*/
init() {
if (this._initialized) return;
try {
if (typeof wx !== 'undefined' && wx.createWebAudioContext) {
this._audioCtx = wx.createWebAudioContext();
}
} catch (e) {
console.warn('[AudioManager] Failed to create WebAudioContext:', e);
}
if (!this._audioCtx) {
console.warn('[AudioManager] WebAudioContext not available, audio disabled.');
return;
}
// Pre-generate all sound effect buffers
this._generateSounds();
this._initialized = true;
console.log('[AudioManager] Initialized with programmatic audio synthesis.');
}
/**
* Generate all game sound effect buffers.
* @private
*/
_generateSounds() {
const ctx = this._audioCtx;
const sampleRate = ctx.sampleRate;
// Shoot sound: short high-frequency burst
this._buffers.set('shoot', this._generateBuffer(sampleRate, 0.08, (i, len) => {
const t = i / len;
const freq = 800 - t * 400;
const envelope = 1 - t;
return Math.sin(2 * Math.PI * freq * i / sampleRate) * envelope * 0.3;
}));
// Explosion (small): noise burst with decay
this._buffers.set('explosion_small', this._generateBuffer(sampleRate, 0.2, (i, len) => {
const t = i / len;
const envelope = (1 - t) * (1 - t);
const noise = Math.random() * 2 - 1;
const tone = Math.sin(2 * Math.PI * 120 * i / sampleRate);
return (noise * 0.6 + tone * 0.4) * envelope * 0.35;
}));
// Explosion (big): longer, deeper noise burst
this._buffers.set('explosion_big', this._generateBuffer(sampleRate, 0.4, (i, len) => {
const t = i / len;
const envelope = (1 - t) * (1 - t);
const noise = Math.random() * 2 - 1;
const tone = Math.sin(2 * Math.PI * 60 * i / sampleRate);
const tone2 = Math.sin(2 * Math.PI * 90 * i / sampleRate);
return (noise * 0.5 + tone * 0.3 + tone2 * 0.2) * envelope * 0.4;
}));
// Hit (bullet hits armor but doesn't destroy): metallic ping
this._buffers.set('hit', this._generateBuffer(sampleRate, 0.1, (i, len) => {
const t = i / len;
const envelope = (1 - t);
return Math.sin(2 * Math.PI * 1200 * i / sampleRate) * envelope * 0.2 +
Math.sin(2 * Math.PI * 2400 * i / sampleRate) * envelope * 0.1;
}));
// Bullet hit wall: short thud
this._buffers.set('hit_wall', this._generateBuffer(sampleRate, 0.06, (i, len) => {
const t = i / len;
const envelope = (1 - t);
const noise = Math.random() * 2 - 1;
return (noise * 0.4 + Math.sin(2 * Math.PI * 300 * i / sampleRate) * 0.6) * envelope * 0.2;
}));
// Power-up pickup: ascending chime
this._buffers.set('powerup', this._generateBuffer(sampleRate, 0.25, (i, len) => {
const t = i / len;
const freq = 400 + t * 800;
const envelope = t < 0.1 ? t / 0.1 : (1 - (t - 0.1) / 0.9);
return (Math.sin(2 * Math.PI * freq * i / sampleRate) * 0.5 +
Math.sin(2 * Math.PI * freq * 1.5 * i / sampleRate) * 0.2) * envelope * 0.3;
}));
// Game over: descending tone
this._buffers.set('gameover', this._generateBuffer(sampleRate, 0.6, (i, len) => {
const t = i / len;
const freq = 400 - t * 250;
const envelope = 1 - t;
return Math.sin(2 * Math.PI * freq * i / sampleRate) * envelope * 0.3;
}));
// Victory: ascending fanfare
this._buffers.set('victory', this._generateBuffer(sampleRate, 0.5, (i, len) => {
const t = i / len;
// Three-note ascending pattern
let freq;
if (t < 0.33) freq = 523; // C5
else if (t < 0.66) freq = 659; // E5
else freq = 784; // G5
const segT = (t % 0.33) / 0.33;
const envelope = segT < 0.1 ? segT / 0.1 : Math.max(0, 1 - (segT - 0.1) / 0.9);
return (Math.sin(2 * Math.PI * freq * i / sampleRate) * 0.4 +
Math.sin(2 * Math.PI * freq * 2 * i / sampleRate) * 0.15) * envelope * 0.3;
}));
// Move: low rumble (very short, for tank movement)
this._buffers.set('move', this._generateBuffer(sampleRate, 0.05, (i, len) => {
const t = i / len;
const envelope = 1 - t;
return Math.sin(2 * Math.PI * 80 * i / sampleRate) * envelope * 0.1;
}));
}
/**
* Generate a PCM audio buffer.
* @private
* @param {number} sampleRate
* @param {number} duration - Duration in seconds.
* @param {Function} generator - (sampleIndex, totalSamples) => sampleValue [-1, 1]
* @returns {AudioBuffer}
*/
_generateBuffer(sampleRate, duration, generator) {
const ctx = this._audioCtx;
const length = Math.floor(sampleRate * duration);
const buffer = ctx.createBuffer(1, length, sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < length; i++) {
data[i] = generator(i, length);
}
return buffer;
}
/**
* Play a sound effect by name.
* @param {string} name - Sound name (shoot, explosion_small, explosion_big, hit, hit_wall, powerup, gameover, victory, move).
*/
playSFX(name) {
if (!this._soundEnabled || !this._initialized || !this._audioCtx) return;
const buffer = this._buffers.get(name);
if (!buffer) return;
try {
const source = this._audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(this._audioCtx.destination);
source.start(0);
} catch (e) {
// Silently ignore playback errors
}
}
/**
* Register a sound effect (kept for backward compatibility).
* @param {string} name
* @param {string} path
*/
register(name, path) {
// No-op: sounds are now generated programmatically
}
/**
* Play background music (no-op for now, can be implemented later with audio files).
* @param {string} path
*/
playBGM(path) {
// BGM requires audio files — not implemented in programmatic mode
}
/** Stop background music. */
stopBGM() {}
/** Pause all audio. */
pauseAll() {}
/** Resume audio. */
resumeAll() {}
/** Destroy audio context. */
destroy() {
if (this._audioCtx) {
try { this._audioCtx.close(); } catch (e) {}
this._audioCtx = null;
}
this._buffers.clear();
this._initialized = false;
}
/** Whether sound effects are enabled. */
get soundEnabled() { return this._soundEnabled; }
set soundEnabled(v) { this._soundEnabled = v; }
/** Whether music is enabled. */
get musicEnabled() { return this._musicEnabled; }
set musicEnabled(v) { this._musicEnabled = v; }
}
module.exports = AudioManager;
+7
View File
@@ -0,0 +1,7 @@
// BattlePassManager - DEPRECATED (removed in monetization-lite)
// This file is intentionally empty. The battle pass system has been removed.
class BattlePassManager {
constructor() {}
reportGameStats() {}
}
module.exports = BattlePassManager;
+217
View File
@@ -0,0 +1,217 @@
/**
* BuffManager.js
* Manages pre-game buff purchases and activation.
* Buffs are one-time per round: Shield (100g) and Double Fire (150g).
*/
/** Buff type definitions. */
const BUFF_TYPE = {
SHIELD: 'SHIELD',
DOUBLE_FIRE: 'DOUBLE_FIRE',
};
/** Buff cost in gold. */
const BUFF_COST = {
[BUFF_TYPE.SHIELD]: 100,
[BUFF_TYPE.DOUBLE_FIRE]: 150,
};
/** Double fire duration in seconds. */
const DOUBLE_FIRE_DURATION = 10;
class BuffManager {
constructor() {
/** @type {Set<string>} Active buffs for the current round. */
this._activeBuffs = new Set();
/** @type {number} Remaining double fire time in seconds. */
this._doubleFireTimer = 0;
/** @type {boolean} Whether shield is currently active. */
this._shieldActive = false;
}
// ============================================================
// Purchase
// ============================================================
/**
* Purchase a buff for the upcoming round.
* Deducts gold via CurrencyManager.
* @param {string} buffType - One of BUFF_TYPE values.
* @returns {{ success: boolean, error?: string }}
*/
purchaseBuff(buffType) {
const cost = BUFF_COST[buffType];
if (cost === undefined) {
return { success: false, error: 'Invalid buff type' };
}
if (this._activeBuffs.has(buffType)) {
return { success: false, error: 'Already purchased' };
}
const cm = GameGlobal.currencyManager;
if (!cm || !cm.hasGold(cost)) {
return { success: false, error: 'Insufficient gold' };
}
const spent = cm.spendGold(cost);
if (!spent) {
return { success: false, error: 'Insufficient gold' };
}
this._activeBuffs.add(buffType);
console.log(`[BuffManager] Purchased buff: ${buffType} for ${cost} gold`);
// Emit event
try {
if (GameGlobal.eventBus) {
GameGlobal.eventBus.emit('buff:purchased', { type: buffType, cost });
}
} catch (e) {}
return { success: true };
}
// ============================================================
// Activation & Game Logic
// ============================================================
/**
* Check if a buff was purchased for this round.
* @param {string} buffType
* @returns {boolean}
*/
hasBuff(buffType) {
return this._activeBuffs.has(buffType);
}
/**
* Get all active buffs for this round.
* @returns {string[]}
*/
getActiveBuffs() {
return Array.from(this._activeBuffs);
}
/**
* Activate buffs at the start of a round.
* Should be called when the game scene initializes.
* @param {object} playerTank - The player tank instance.
*/
activateBuffs(playerTank) {
if (!playerTank) return;
// Shield buff: add a shield layer to the player tank
if (this._activeBuffs.has(BUFF_TYPE.SHIELD)) {
this._shieldActive = true;
playerTank._buffShield = true;
console.log('[BuffManager] Shield buff activated');
}
// Double fire buff: start the timer
if (this._activeBuffs.has(BUFF_TYPE.DOUBLE_FIRE)) {
this._doubleFireTimer = DOUBLE_FIRE_DURATION;
playerTank._buffDoubleFire = true;
console.log('[BuffManager] Double fire buff activated');
}
}
/**
* Update buff timers. Called every frame from GameScene.
* @param {number} dt - Delta time in seconds.
* @param {object} playerTank - The player tank instance.
*/
update(dt, playerTank) {
if (!playerTank) return;
// Double fire timer countdown
if (this._doubleFireTimer > 0) {
this._doubleFireTimer -= dt;
if (this._doubleFireTimer <= 0) {
this._doubleFireTimer = 0;
playerTank._buffDoubleFire = false;
console.log('[BuffManager] Double fire buff expired');
}
}
}
/**
* Consume the shield buff (called when player takes damage).
* @param {object} playerTank - The player tank instance.
* @returns {boolean} True if shield was consumed (damage absorbed).
*/
consumeShield(playerTank) {
if (this._shieldActive && playerTank && playerTank._buffShield) {
this._shieldActive = false;
playerTank._buffShield = false;
console.log('[BuffManager] Shield buff consumed');
// Emit event for visual feedback
try {
if (GameGlobal.eventBus) {
GameGlobal.eventBus.emit('buff:shield:consumed');
}
} catch (e) {}
return true;
}
return false;
}
/**
* Check if double fire is currently active.
* @returns {boolean}
*/
isDoubleFireActive() {
return this._doubleFireTimer > 0;
}
/**
* Get remaining double fire time in seconds.
* @returns {number}
*/
getDoubleFireRemaining() {
return Math.max(0, this._doubleFireTimer);
}
/**
* Check if shield buff is still active (not yet consumed).
* @returns {boolean}
*/
isShieldActive() {
return this._shieldActive;
}
// ============================================================
// Round Lifecycle
// ============================================================
/**
* Clear all buffs at the end of a round.
* Must be called when the game ends (win or lose).
*/
clearBuffs() {
this._activeBuffs.clear();
this._doubleFireTimer = 0;
this._shieldActive = false;
console.log('[BuffManager] All buffs cleared');
}
/**
* Get buff cost.
* @param {string} buffType
* @returns {number}
*/
getBuffCost(buffType) {
return BUFF_COST[buffType] || 0;
}
}
// Export constants
BuffManager.BUFF_TYPE = BUFF_TYPE;
BuffManager.BUFF_COST = BUFF_COST;
BuffManager.DOUBLE_FIRE_DURATION = DOUBLE_FIRE_DURATION;
module.exports = BuffManager;
+383
View File
@@ -0,0 +1,383 @@
/**
* CollisionManager.js
* Handles all collision detection between game entities each frame:
* bullet↔terrain, bullet↔tank, bullet↔bullet, bullet↔base, tank↔tank.
*/
const {
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
MAP_WIDTH,
MAP_HEIGHT,
TERRAIN,
GRID_ROWS,
GRID_COLS,
} = require('../base/GameGlobal');
class CollisionManager {
/**
* @param {object} deps
* @param {import('../managers/MapManager')} deps.mapManager
* @param {Function} deps.onExplosion - Callback(x, y, isBig) to spawn explosion.
* @param {import('../base/EventBus')} deps.eventBus
*/
constructor(deps) {
this._map = deps.mapManager;
this._onExplosion = deps.onExplosion;
this._eventBus = deps.eventBus;
}
/**
* Run all collision checks for one frame.
* @param {object} entities
* @param {import('../entities/PlayerTank')} entities.player
* @param {Array<import('../entities/Tank')>} entities.enemies
* @param {Array<import('../entities/Bullet')>} entities.bullets
*/
update(entities) {
const { player, enemies, bullets } = entities;
const aliveBullets = bullets.filter((b) => b.alive);
const aliveEnemies = enemies.filter((e) => e.alive);
// 1. Bullet ↔ Terrain / Base
this._checkBulletTerrain(aliveBullets);
// 2. Bullet ↔ Tank
this._checkBulletTank(aliveBullets, player, aliveEnemies);
// 3. Bullet ↔ Bullet (player vs enemy)
this._checkBulletBullet(aliveBullets);
// 4. Tank ↔ Tank (player vs enemies)
// Note: In classic tank game, tanks block each other but don't destroy on contact.
// Player death on contact is optional - we implement it per requirements.
this._checkTankTank(player, aliveEnemies);
}
/**
* Check bullets against terrain tiles.
* @private
*/
_checkBulletTerrain(bullets) {
for (const bullet of bullets) {
if (!bullet.alive) continue;
const { row, col } = this._map.pixelToGrid(bullet.x, bullet.y);
// Out of map bounds
if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) {
bullet.destroy();
this._onExplosion(bullet.x, bullet.y, false);
continue;
}
const terrain = this._map.getTerrain(row, col);
if (terrain === TERRAIN.BRICK) {
// Destroy brick
this._map.setTerrain(row, col, TERRAIN.EMPTY);
// Lv3 bullets destroy adjacent bricks too
if (bullet.canBreakSteel) {
this._destroyAdjacentBricks(row, col, bullet.direction);
}
bullet.destroy();
this._onExplosion(bullet.x, bullet.y, false);
} else if (terrain === TERRAIN.BASE_WALL) {
// Player bullets are immune to base wall
if (bullet.owner === 'player') {
bullet.destroy();
continue;
}
// Base wall has HP - use bulletHitTerrain for HP tracking
const result = this._map.bulletHitTerrain(row, col, bullet.canBreakSteel);
// Lv3 bullets also damage adjacent base walls
if (bullet.canBreakSteel) {
this._destroyAdjacentBricks(row, col, bullet.direction);
}
bullet.destroy();
this._onExplosion(bullet.x, bullet.y, false);
} else if (terrain === TERRAIN.STEEL) {
if (bullet.canBreakSteel) {
this._map.setTerrain(row, col, TERRAIN.EMPTY);
bullet.destroy();
this._onExplosion(bullet.x, bullet.y, false);
} else {
// Bullet blocked by steel
bullet.destroy();
this._onExplosion(bullet.x, bullet.y, false);
}
} else if (terrain === TERRAIN.BASE) {
// Player bullets are immune to base
if (bullet.owner === 'player') {
bullet.destroy();
continue;
}
// Base hit by enemy bullet!
this._map._baseDestroyed = true;
bullet.destroy();
this._onExplosion(bullet.x, bullet.y, true);
this._eventBus.emit('base:destroyed');
}
// RIVER and FOREST: bullets pass through
}
}
/**
* Destroy adjacent bricks for Lv3 bullet splash.
* @private
*/
_destroyAdjacentBricks(row, col, direction) {
const { DIRECTION } = require('../base/GameGlobal');
const offsets =
direction === DIRECTION.UP || direction === DIRECTION.DOWN
? [[0, -1], [0, 1]] // horizontal neighbors
: [[-1, 0], [1, 0]]; // vertical neighbors
for (const [dr, dc] of offsets) {
const nr = row + dr;
const nc = col + dc;
const t = this._map.getTerrain(nr, nc);
if (t === TERRAIN.BRICK) {
this._map.setTerrain(nr, nc, TERRAIN.EMPTY);
} else if (t === TERRAIN.BASE_WALL) {
// Base wall has HP - use bulletHitTerrain for HP tracking
this._map.bulletHitTerrain(nr, nc, false);
}
}
}
/**
* Check bullets against tanks.
* @private
*/
_checkBulletTank(bullets, player, enemies) {
for (const bullet of bullets) {
if (!bullet.alive) continue;
const bb = bullet.getBounds();
if (bullet.owner === 'player') {
// Player bullet hits enemy
for (const enemy of enemies) {
if (!enemy.alive) continue;
const eb = enemy.getBounds();
if (this._rectsOverlap(bb, eb)) {
const destroyed = enemy.takeDamage(1);
bullet.destroy();
if (destroyed) {
this._onExplosion(enemy.x, enemy.y, true);
this._eventBus.emit('enemy:destroyed', { enemy });
} else {
this._onExplosion(bullet.x, bullet.y, false);
this._eventBus.emit('enemy:hit', { enemy });
if (GameGlobal.audioManager) GameGlobal.audioManager.playSFX('hit');
}
break;
}
}
} else {
// Enemy bullet hits player
if (player && player.alive) {
const pb = player.getBounds();
if (this._rectsOverlap(bb, pb)) {
const destroyed = player.takeDamage(1);
bullet.destroy();
if (destroyed) {
this._onExplosion(player.x, player.y, true);
this._eventBus.emit('player:destroyed');
} else {
this._onExplosion(bullet.x, bullet.y, false);
}
}
}
}
}
}
/**
* Check bullet-bullet collisions (player vs enemy bullets cancel out).
* @private
*/
_checkBulletBullet(bullets) {
for (let i = 0; i < bullets.length; i++) {
if (!bullets[i].alive) continue;
for (let j = i + 1; j < bullets.length; j++) {
if (!bullets[j].alive) continue;
// Only cancel if different owners
if (bullets[i].owner === bullets[j].owner) continue;
const a = bullets[i].getBounds();
const b = bullets[j].getBounds();
if (this._rectsOverlap(a, b)) {
const mx = (bullets[i].x + bullets[j].x) / 2;
const my = (bullets[i].y + bullets[j].y) / 2;
bullets[i].destroy();
bullets[j].destroy();
this._onExplosion(mx, my, false);
}
}
}
}
/**
* Check tank-tank collisions.
* Classic Battle City behavior: tanks block each other on contact,
* they are pushed apart so they don't overlap. No damage is dealt.
* @private
*/
_checkTankTank(player, enemies) {
if (!player || !player.alive) return;
for (const enemy of enemies) {
if (!enemy.alive) continue;
if (player.collidesWith(enemy)) {
// Push tanks apart — resolve overlap along the axis with smallest penetration
this._separateTanks(player, enemy);
}
}
// Also prevent enemies from overlapping each other
for (let i = 0; i < enemies.length; i++) {
if (!enemies[i].alive) continue;
for (let j = i + 1; j < enemies.length; j++) {
if (!enemies[j].alive) continue;
if (enemies[i].collidesWith(enemies[j])) {
this._separateTanks(enemies[i], enemies[j]);
}
}
}
}
/**
* Check if a tank position is valid (within map bounds and not colliding with terrain).
* @private
*/
_isPositionValid(tank, x, y) {
const hs = tank.halfSize;
const left = x - hs;
const top = y - hs;
const right = x + hs;
const bottom = y + hs;
// Map boundary check
if (
left < MAP_OFFSET_X ||
top < MAP_OFFSET_Y ||
right > MAP_OFFSET_X + MAP_WIDTH ||
bottom > MAP_OFFSET_Y + MAP_HEIGHT
) {
return false;
}
// Terrain collision check
if (this._map.rectCollidesWithTerrain(left, top, tank.size, tank.size)) {
return false;
}
return true;
}
/**
* Push two overlapping tanks apart along the axis of least penetration.
* Validates new positions against map bounds and terrain before applying.
* @private
*/
_separateTanks(tankA, tankB) {
const a = tankA.getBounds();
const b = tankB.getBounds();
// Calculate overlap on each axis
const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
if (overlapX <= 0 || overlapY <= 0) return; // no real overlap
if (overlapX < overlapY) {
// Separate along X axis
const sign = tankA.x < tankB.x ? -1 : 1;
const halfPush = overlapX / 2;
const newAx = tankA.x + sign * halfPush;
const newBx = tankB.x - sign * halfPush;
const aValid = this._isPositionValid(tankA, newAx, tankA.y);
const bValid = this._isPositionValid(tankB, newBx, tankB.y);
if (aValid && bValid) {
tankA.x = newAx;
tankB.x = newBx;
} else if (aValid && !bValid) {
// B can't move, push A the full overlap
const fullAx = tankA.x + sign * overlapX;
if (this._isPositionValid(tankA, fullAx, tankA.y)) {
tankA.x = fullAx;
} else {
tankA.x = newAx; // at least push half
}
} else if (!aValid && bValid) {
// A can't move, push B the full overlap
const fullBx = tankB.x - sign * overlapX;
if (this._isPositionValid(tankB, fullBx, tankB.y)) {
tankB.x = fullBx;
} else {
tankB.x = newBx; // at least push half
}
}
// If neither is valid, don't move either (both stuck)
} else {
// Separate along Y axis
const sign = tankA.y < tankB.y ? -1 : 1;
const halfPush = overlapY / 2;
const newAy = tankA.y + sign * halfPush;
const newBy = tankB.y - sign * halfPush;
const aValid = this._isPositionValid(tankA, tankA.x, newAy);
const bValid = this._isPositionValid(tankB, tankB.x, newBy);
if (aValid && bValid) {
tankA.y = newAy;
tankB.y = newBy;
} else if (aValid && !bValid) {
const fullAy = tankA.y + sign * overlapY;
if (this._isPositionValid(tankA, tankA.x, fullAy)) {
tankA.y = fullAy;
} else {
tankA.y = newAy;
}
} else if (!aValid && bValid) {
const fullBy = tankB.y - sign * overlapY;
if (this._isPositionValid(tankB, tankB.x, fullBy)) {
tankB.y = fullBy;
} else {
tankB.y = newBy;
}
}
// If neither is valid, don't move either (both stuck)
}
}
/**
* AABB overlap test.
* @private
*/
_rectsOverlap(a, b) {
return (
a.x < b.x + b.w &&
a.x + a.w > b.x &&
a.y < b.y + b.h &&
a.y + a.h > b.y
);
}
}
module.exports = CollisionManager;
+227
View File
@@ -0,0 +1,227 @@
/**
* ComplianceManager.js
* Manages underage protection, probability disclosure, and anti-cheat measures.
* Ensures compliance with Chinese gaming regulations and WeChat platform rules.
*/
class ComplianceManager {
constructor() {
this._isMinor = false;
this._monthlySpending = 0;
this._dailyAdCount = 0;
this._trackingDate = '';
this._load();
this._checkMinorStatus();
}
// ============================================================
// Persistence
// ============================================================
/** @private */
_getTodayKey() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
/** @private */
_getMonthKey() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
}
/** @private */
_load() {
try {
if (GameGlobal && GameGlobal.storageManager) {
const data = GameGlobal.storageManager.get('compliance', null);
if (data) {
this._isMinor = data.isMinor || false;
// Monthly spending
const currentMonth = this._getMonthKey();
if (data.spendingMonth === currentMonth) {
this._monthlySpending = data.monthlySpending || 0;
}
// Daily ad count
const today = this._getTodayKey();
if (data.adDate === today) {
this._dailyAdCount = data.dailyAdCount || 0;
}
this._trackingDate = today;
}
}
} catch (e) {
console.warn('[ComplianceManager] Failed to load:', e);
}
}
/** @private */
_save() {
try {
if (GameGlobal && GameGlobal.storageManager) {
GameGlobal.storageManager.set('compliance', {
isMinor: this._isMinor,
spendingMonth: this._getMonthKey(),
monthlySpending: this._monthlySpending,
adDate: this._getTodayKey(),
dailyAdCount: this._dailyAdCount,
});
}
} catch (e) {}
}
// ============================================================
// Minor Status Detection
// ============================================================
/**
* Check if the user is a minor via WeChat platform API.
* @private
*/
_checkMinorStatus() {
// WeChat provides user age info through specific APIs
// In production, this would call wx.getUserInfo or a server-side check
// For now, default to non-minor
try {
if (typeof wx !== 'undefined' && typeof wx.getSetting === 'function') {
// Placeholder: in production, check real-name verification status
console.log('[ComplianceManager] Minor status check: defaulting to adult');
}
} catch (e) {}
}
// ============================================================
// Public API
// ============================================================
/**
* Whether the current user is identified as a minor.
* @returns {boolean}
*/
isMinor() {
return this._isMinor;
}
/**
* Set minor status (called after real-name verification).
* @param {boolean} isMinor
*/
setMinorStatus(isMinor) {
this._isMinor = isMinor;
this._save();
}
/**
* Check if a purchase is allowed for the current user.
* Minors: monthly limit ¥400, single purchase > ¥50 requires confirmation.
* @param {number} amountFen - Purchase amount in fen (分).
* @returns {{ allowed: boolean, needsConfirmation: boolean, reason?: string }}
*/
checkPurchaseRestriction(amountFen) {
if (!this._isMinor) {
return { allowed: true, needsConfirmation: false };
}
const amountYuan = amountFen / 100;
// Monthly limit: ¥400
const newTotal = this._monthlySpending + amountFen;
if (newTotal > 40000) { // 400 yuan in fen
return {
allowed: false,
needsConfirmation: false,
reason: 'monthly_limit',
};
}
// Single purchase > ¥50 needs confirmation
if (amountYuan > 50) {
return {
allowed: true,
needsConfirmation: true,
reason: 'large_purchase',
};
}
return { allowed: true, needsConfirmation: false };
}
/**
* Record a successful purchase amount.
* @param {number} amountFen
*/
recordPurchase(amountFen) {
this._monthlySpending += amountFen;
this._save();
}
/**
* Check if an ad can be shown to the current user.
* Minors: max 5 ads per day.
* @returns {boolean}
*/
canShowAd() {
if (!this._isMinor) return true;
const today = this._getTodayKey();
if (this._trackingDate !== today) {
this._trackingDate = today;
this._dailyAdCount = 0;
}
return this._dailyAdCount < 5;
}
/**
* Record an ad shown to the user.
*/
recordAdShown() {
if (!this._isMinor) return;
const today = this._getTodayKey();
if (this._trackingDate !== today) {
this._trackingDate = today;
this._dailyAdCount = 0;
}
this._dailyAdCount++;
this._save();
}
/**
* Validate game session data for anti-cheat.
* Checks for impossible stats (e.g., too many kills in too short time).
* @param {object} stats - { kills, timeElapsed, score }
* @returns {{ valid: boolean, flags: string[] }}
*/
validateGameSession(stats) {
const flags = [];
if (!stats) return { valid: true, flags };
// Check impossible kill rate (>10 kills per minute)
if (stats.kills && stats.timeElapsed) {
const killsPerMinute = stats.kills / (stats.timeElapsed / 60);
if (killsPerMinute > 10) {
flags.push('suspicious_kill_rate');
}
}
// Check impossible score
if (stats.score && stats.score > 100000) {
flags.push('suspicious_score');
}
// Check suspicious ad reward frequency
// (anti-cheat for ad reward manipulation)
return {
valid: flags.length === 0,
flags,
};
}
}
module.exports = ComplianceManager;
+172
View File
@@ -0,0 +1,172 @@
/**
* CurrencyManager.js
* Manages the single in-game currency: Gold.
* Simplified from the original multi-currency system (monetization-lite).
* Provides add/spend/get operations with EventBus notifications,
* StorageManager persistence, and overflow protection.
*/
/** Maximum gold cap to prevent overflow. */
const MAX_GOLD = 999999;
class CurrencyManager {
constructor() {
this._gold = 0;
this._load();
}
// ============================================================
// Persistence
// ============================================================
/**
* Load currency data from StorageManager.
* @private
*/
_load() {
try {
if (GameGlobal && GameGlobal.storageManager) {
const data = GameGlobal.storageManager.get('currency', null);
if (data) {
this._gold = data.gold || 0;
}
}
} catch (e) {
console.warn('[CurrencyManager] Failed to load currency data:', e);
}
}
/**
* Save currency data to StorageManager.
* @private
*/
_save() {
try {
if (GameGlobal && GameGlobal.storageManager) {
GameGlobal.storageManager.set('currency', {
gold: this._gold,
});
}
} catch (e) {
console.warn('[CurrencyManager] Failed to save currency data:', e);
}
}
/**
* Emit a currency change event via EventBus.
* @private
*/
_emitGoldChanged() {
try {
if (GameGlobal && GameGlobal.eventBus) {
GameGlobal.eventBus.emit('currency:gold:changed', this._gold);
}
} catch (e) {}
}
// ============================================================
// Gold
// ============================================================
/**
* Get current gold amount.
* @returns {number}
*/
getGold() {
return this._gold;
}
/**
* Add gold (capped at MAX_GOLD).
* @param {number} amount - Must be positive.
* @returns {number} Actual amount added (may be less if capped).
*/
addGold(amount) {
if (amount <= 0) return 0;
const before = this._gold;
this._gold = Math.min(this._gold + Math.floor(amount), MAX_GOLD);
const added = this._gold - before;
if (added > 0) {
this._save();
this._emitGoldChanged();
}
return added;
}
/**
* Spend gold.
* @param {number} amount - Must be positive.
* @returns {boolean} True if successful, false if insufficient.
*/
spendGold(amount) {
if (amount <= 0) return true;
if (this._gold < amount) {
// Emit insufficient event for UI to handle
try {
if (GameGlobal && GameGlobal.eventBus) {
GameGlobal.eventBus.emit('currency:gold:insufficient', { required: amount, current: this._gold });
}
} catch (e) {}
return false;
}
this._gold -= Math.floor(amount);
this._save();
this._emitGoldChanged();
return true;
}
/**
* Check if player has enough gold.
* @param {number} amount
* @returns {boolean}
*/
hasGold(amount) {
return this._gold >= amount;
}
/**
* Check if gold is at maximum cap.
* @returns {boolean}
*/
isGoldFull() {
return this._gold >= MAX_GOLD;
}
/**
* Get the maximum gold cap.
* @returns {number}
*/
getMaxGold() {
return MAX_GOLD;
}
// ============================================================
// Cloud Sync
// ============================================================
/**
* Get currency data for cloud sync.
* @returns {object}
*/
getCloudSyncData() {
return {
gold: this._gold,
};
}
/**
* Restore currency data from cloud (merge: keep higher values).
* @param {object} cloudData
*/
restoreFromCloud(cloudData) {
if (!cloudData) return;
if (cloudData.gold !== undefined && cloudData.gold > this._gold) {
this._gold = Math.min(cloudData.gold, MAX_GOLD);
this._save();
this._emitGoldChanged();
}
}
}
module.exports = CurrencyManager;
+472
View File
@@ -0,0 +1,472 @@
/**
* MapManager.js
* Manages the tile-based game map: loading, rendering, terrain state, and collision queries.
*/
const {
GRID_COLS,
GRID_ROWS,
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
TERRAIN,
COLORS,
} = require('../base/GameGlobal');
class MapManager {
constructor() {
/** @type {number[][]} 2D grid of terrain types */
this._grid = [];
/** @type {boolean} Whether the base has been destroyed */
this._baseDestroyed = false;
/** @type {boolean} Whether base walls are temporarily steel */
this._baseSteelTimer = 0;
/** @type {number[][]} Backup of original base wall positions */
this._baseWallPositions = [];
/** @type {Object} HP map for base wall tiles, keyed by 'row,col' */
this._baseWallHP = {};
/** Base wall default HP (hits required to destroy) */
this.BASE_WALL_MAX_HP = 3;
}
/**
* Load a level grid.
* @param {number[][]} grid - GRID_ROWS × GRID_COLS array of terrain values.
*/
loadGrid(grid) {
// Deep clone so we don't mutate level data
this._grid = grid.map((row) => [...row]);
this._baseDestroyed = false;
this._baseSteelTimer = 0;
// Record base wall positions for shovel power-up and initialize HP
this._baseWallPositions = [];
this._baseWallHP = {};
for (let r = 0; r < GRID_ROWS; r++) {
for (let c = 0; c < GRID_COLS; c++) {
if (this._grid[r][c] === TERRAIN.BASE_WALL) {
this._baseWallPositions.push([r, c]);
this._baseWallHP[`${r},${c}`] = this.BASE_WALL_MAX_HP;
}
}
}
}
/**
* Update map state (e.g., shovel timer).
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (this._baseSteelTimer > 0) {
this._baseSteelTimer -= dt * 1000;
if (this._baseSteelTimer <= 0) {
this._baseSteelTimer = 0;
// Revert steel walls back to brick and restore HP
for (const [r, c] of this._baseWallPositions) {
if (this._grid[r][c] === TERRAIN.STEEL) {
this._grid[r][c] = TERRAIN.BASE_WALL;
this._baseWallHP[`${r},${c}`] = this.BASE_WALL_MAX_HP;
}
}
}
}
}
/**
* Render the map tiles.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
for (let r = 0; r < GRID_ROWS; r++) {
for (let c = 0; c < GRID_COLS; c++) {
const terrain = this._grid[r][c];
if (terrain === TERRAIN.EMPTY) continue;
const x = MAP_OFFSET_X + c * TILE_SIZE;
const y = MAP_OFFSET_Y + r * TILE_SIZE;
switch (terrain) {
case TERRAIN.BRICK:
this._drawBrick(ctx, x, y);
break;
case TERRAIN.BASE_WALL:
this._drawBaseWall(ctx, x, y, r, c);
break;
case TERRAIN.STEEL:
this._drawSteel(ctx, x, y);
break;
case TERRAIN.RIVER:
this._drawRiver(ctx, x, y);
break;
case TERRAIN.FOREST:
// Forest is drawn in a separate pass (on top of tanks)
break;
case TERRAIN.BASE:
this._drawBase(ctx, x, y);
break;
}
}
}
}
/**
* Render the forest overlay (drawn after tanks so it covers them).
* @param {CanvasRenderingContext2D} ctx
*/
renderForestOverlay(ctx) {
for (let r = 0; r < GRID_ROWS; r++) {
for (let c = 0; c < GRID_COLS; c++) {
if (this._grid[r][c] === TERRAIN.FOREST) {
const x = MAP_OFFSET_X + c * TILE_SIZE;
const y = MAP_OFFSET_Y + r * TILE_SIZE;
this._drawForest(ctx, x, y);
}
}
}
}
// ============================================================
// Tile Drawing Methods
// ============================================================
_drawBrick(ctx, x, y) {
const s = TILE_SIZE;
ctx.fillStyle = COLORS.BRICK;
ctx.fillRect(x, y, s, s);
// Brick pattern (mortar lines)
ctx.strokeStyle = '#8B4513';
ctx.lineWidth = 1;
// Horizontal line
ctx.beginPath();
ctx.moveTo(x, y + s / 2);
ctx.lineTo(x + s, y + s / 2);
ctx.stroke();
// Vertical lines (offset pattern)
ctx.beginPath();
ctx.moveTo(x + s / 2, y);
ctx.lineTo(x + s / 2, y + s / 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + s / 4, y + s / 2);
ctx.lineTo(x + s / 4, y + s);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + s * 3 / 4, y + s / 2);
ctx.lineTo(x + s * 3 / 4, y + s);
ctx.stroke();
}
_drawSteel(ctx, x, y) {
const s = TILE_SIZE;
ctx.fillStyle = COLORS.STEEL;
ctx.fillRect(x, y, s, s);
// Steel shine effect
ctx.fillStyle = '#A0A0A0';
ctx.fillRect(x + 2, y + 2, s / 2 - 2, s / 2 - 2);
ctx.fillRect(x + s / 2 + 1, y + s / 2 + 1, s / 2 - 3, s / 2 - 3);
// Border
ctx.strokeStyle = '#606060';
ctx.lineWidth = 1;
ctx.strokeRect(x + 0.5, y + 0.5, s - 1, s - 1);
}
_drawRiver(ctx, x, y) {
const s = TILE_SIZE;
ctx.fillStyle = COLORS.RIVER;
ctx.fillRect(x, y, s, s);
// Wave pattern
ctx.strokeStyle = '#5B9BD5';
ctx.lineWidth = 1;
for (let i = 0; i < 3; i++) {
const wy = y + s * (i + 1) / 4;
ctx.beginPath();
ctx.moveTo(x, wy);
ctx.quadraticCurveTo(x + s / 4, wy - 2, x + s / 2, wy);
ctx.quadraticCurveTo(x + s * 3 / 4, wy + 2, x + s, wy);
ctx.stroke();
}
}
_drawForest(ctx, x, y) {
const s = TILE_SIZE;
ctx.fillStyle = COLORS.FOREST;
ctx.globalAlpha = 0.85;
ctx.fillRect(x, y, s, s);
// Tree pattern
ctx.fillStyle = '#008000';
const r = s / 4;
ctx.beginPath();
ctx.arc(x + s / 3, y + s / 3, r, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(x + s * 2 / 3, y + s / 2, r, 0, Math.PI * 2);
ctx.fill();
ctx.beginPath();
ctx.arc(x + s / 2, y + s * 2 / 3, r, 0, Math.PI * 2);
ctx.fill();
ctx.globalAlpha = 1;
}
/**
* Draw a base wall tile with visual HP indicator.
* @private
*/
_drawBaseWall(ctx, x, y, row, col) {
const s = TILE_SIZE;
const key = `${row},${col}`;
const hp = this._baseWallHP[key] || 0;
const maxHP = this.BASE_WALL_MAX_HP;
const ratio = hp / maxHP;
// Base color darkens as HP decreases
if (ratio > 0.66) {
ctx.fillStyle = '#C47832'; // full HP - bright brick
} else if (ratio > 0.33) {
ctx.fillStyle = '#A05A20'; // medium HP - darker
} else {
ctx.fillStyle = '#7A3E10'; // low HP - very dark
}
ctx.fillRect(x, y, s, s);
// Brick pattern (mortar lines)
ctx.strokeStyle = '#5A2D0C';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y + s / 2);
ctx.lineTo(x + s, y + s / 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + s / 2, y);
ctx.lineTo(x + s / 2, y + s / 2);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + s / 4, y + s / 2);
ctx.lineTo(x + s / 4, y + s);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + s * 3 / 4, y + s / 2);
ctx.lineTo(x + s * 3 / 4, y + s);
ctx.stroke();
// Reinforcement border to distinguish from normal brick
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 1.5;
ctx.strokeRect(x + 1, y + 1, s - 2, s - 2);
// HP indicator dots at top
const dotR = 2;
const dotSpacing = 8;
const startX = x + s / 2 - ((maxHP - 1) * dotSpacing) / 2;
for (let i = 0; i < maxHP; i++) {
ctx.fillStyle = i < hp ? '#FFD700' : '#333333';
ctx.beginPath();
ctx.arc(startX + i * dotSpacing, y + 5, dotR, 0, Math.PI * 2);
ctx.fill();
}
}
_drawBase(ctx, x, y) {
const s = TILE_SIZE;
if (this._baseDestroyed) {
// Destroyed base
ctx.fillStyle = '#333333';
ctx.fillRect(x, y, s, s);
ctx.strokeStyle = '#FF0000';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x + 2, y + 2);
ctx.lineTo(x + s - 2, y + s - 2);
ctx.moveTo(x + s - 2, y + 2);
ctx.lineTo(x + 2, y + s - 2);
ctx.stroke();
} else {
// Eagle / base icon
ctx.fillStyle = COLORS.BASE;
ctx.fillRect(x, y, s, s);
// Simple eagle shape
ctx.fillStyle = '#000000';
ctx.font = `${Math.floor(s * 0.7)}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('🦅', x + s / 2, y + s / 2);
}
}
// ============================================================
// Collision & Query Methods
// ============================================================
/**
* Get terrain type at a grid position.
* @param {number} row
* @param {number} col
* @returns {number} Terrain type, or -1 if out of bounds.
*/
getTerrain(row, col) {
if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) return -1;
return this._grid[row][col];
}
/**
* Set terrain at a grid position.
* @param {number} row
* @param {number} col
* @param {number} terrain
*/
setTerrain(row, col, terrain) {
if (row < 0 || row >= GRID_ROWS || col < 0 || col >= GRID_COLS) return;
this._grid[row][col] = terrain;
}
/**
* Convert pixel coordinates to grid coordinates.
* @param {number} px - Pixel X (screen space).
* @param {number} py - Pixel Y (screen space).
* @returns {{row: number, col: number}}
*/
pixelToGrid(px, py) {
const col = Math.floor((px - MAP_OFFSET_X) / TILE_SIZE);
const row = Math.floor((py - MAP_OFFSET_Y) / TILE_SIZE);
return { row, col };
}
/**
* Convert grid coordinates to pixel coordinates (top-left of tile).
* @param {number} row
* @param {number} col
* @returns {{x: number, y: number}}
*/
gridToPixel(row, col) {
return {
x: MAP_OFFSET_X + col * TILE_SIZE,
y: MAP_OFFSET_Y + row * TILE_SIZE,
};
}
/**
* Check if a terrain tile blocks tank movement.
* @param {number} row
* @param {number} col
* @returns {boolean}
*/
isTankBlocking(row, col) {
const t = this.getTerrain(row, col);
if (t === -1) return true; // out of bounds
return (
t === TERRAIN.BRICK ||
t === TERRAIN.STEEL ||
t === TERRAIN.RIVER ||
t === TERRAIN.BASE ||
t === TERRAIN.BASE_WALL
);
}
/**
* Check if a terrain tile blocks bullets.
* @param {number} row
* @param {number} col
* @param {boolean} canBreakSteel - Whether the bullet can break steel.
* @returns {'block'|'destroy'|'pass'} Result of bullet hitting this tile.
*/
bulletHitTerrain(row, col, canBreakSteel) {
const t = this.getTerrain(row, col);
if (t === -1) return 'block'; // out of bounds = wall
switch (t) {
case TERRAIN.BRICK:
// Destroy the brick
this._grid[row][col] = TERRAIN.EMPTY;
return 'destroy';
case TERRAIN.BASE_WALL: {
// Base wall has HP, reduce it
const key = `${row},${col}`;
const currentHP = (this._baseWallHP[key] || 1) - 1;
this._baseWallHP[key] = currentHP;
if (currentHP <= 0) {
this._grid[row][col] = TERRAIN.EMPTY;
return 'destroy';
}
return 'block'; // damaged but not destroyed
}
case TERRAIN.STEEL:
if (canBreakSteel) {
this._grid[row][col] = TERRAIN.EMPTY;
return 'destroy';
}
return 'block';
case TERRAIN.BASE:
this._baseDestroyed = true;
return 'destroy';
case TERRAIN.RIVER:
return 'pass'; // bullets fly over river
case TERRAIN.FOREST:
return 'pass'; // bullets pass through forest
default:
return 'pass';
}
}
/**
* Check if a rectangular area collides with any blocking terrain.
* Used for tank movement collision.
* @param {number} x - Left edge (pixel).
* @param {number} y - Top edge (pixel).
* @param {number} w - Width.
* @param {number} h - Height.
* @returns {boolean} True if any blocking tile overlaps.
*/
rectCollidesWithTerrain(x, y, w, h) {
// Get grid range that the rect covers
const startCol = Math.floor((x - MAP_OFFSET_X) / TILE_SIZE);
const endCol = Math.floor((x + w - 1 - MAP_OFFSET_X) / TILE_SIZE);
const startRow = Math.floor((y - MAP_OFFSET_Y) / TILE_SIZE);
const endRow = Math.floor((y + h - 1 - MAP_OFFSET_Y) / TILE_SIZE);
for (let r = startRow; r <= endRow; r++) {
for (let c = startCol; c <= endCol; c++) {
if (this.isTankBlocking(r, c)) {
return true;
}
}
}
return false;
}
/**
* Activate shovel power-up: convert base walls to steel temporarily.
* @param {number} duration - Duration in ms.
*/
activateShovel(duration) {
this._baseSteelTimer = duration;
for (const [r, c] of this._baseWallPositions) {
if (this._grid[r][c] === TERRAIN.BASE_WALL || this._grid[r][c] === TERRAIN.EMPTY) {
this._grid[r][c] = TERRAIN.STEEL;
}
}
}
/** Whether the base has been destroyed. */
get baseDestroyed() {
return this._baseDestroyed;
}
/** Get the raw grid (read-only reference). */
get grid() {
return this._grid;
}
}
module.exports = MapManager;
+528
View File
@@ -0,0 +1,528 @@
/**
* NetworkManager.js
* Manages WebSocket connection for PVP online multiplayer.
* Handles connection lifecycle, heartbeat, reconnection, and message routing.
*/
const { NET_MSG } = require('../base/GameGlobal');
class NetworkManager {
constructor() {
/** @type {WebSocket|null} */
this._ws = null;
/** @type {string} Server URL */
this._serverUrl = '';
/** @type {boolean} */
this._connected = false;
/** @type {boolean} */
this._connecting = false;
/** @type {string|null} Current room ID */
this._roomId = null;
/** @type {number} Player slot (1 or 2) */
this._playerSlot = 0;
/** @type {string} Unique player ID */
this._playerId = '';
// Heartbeat
this._heartbeatInterval = null;
this._heartbeatTimeout = null;
this._heartbeatMs = 5000;
this._heartbeatTimeoutMs = 10000;
// Reconnection
this._reconnectAttempts = 0;
this._maxReconnectAttempts = 3;
this._reconnectDelay = 2000;
this._reconnectTimer = null;
this._shouldReconnect = false;
// Message handlers
/** @type {Map<string, Array<Function>>} */
this._handlers = new Map();
// Latency tracking
this._lastPingTime = 0;
this._latency = 0;
// Generate a unique player ID
this._playerId = this._generatePlayerId();
}
/**
* Connect to the WebSocket server.
* @param {string} serverUrl - WebSocket server URL (e.g., 'wss://example.com/ws').
* @returns {Promise<boolean>} Whether connection succeeded.
*/
connect(serverUrl) {
return new Promise((resolve) => {
if (this._connected || this._connecting) {
resolve(this._connected);
return;
}
this._serverUrl = serverUrl;
this._connecting = true;
this._shouldReconnect = true;
try {
this._ws = wx.connectSocket({
url: serverUrl,
header: { 'content-type': 'application/json' },
});
this._ws.onOpen(() => {
console.log('[NetworkManager] Connected to server');
this._connected = true;
this._connecting = false;
this._reconnectAttempts = 0;
this._startHeartbeat();
this._emit('connected');
resolve(true);
});
this._ws.onMessage((res) => {
this._handleMessage(res.data);
});
this._ws.onError((err) => {
console.error('[NetworkManager] WebSocket error:', err);
this._connecting = false;
this._emit('error', err);
resolve(false);
});
this._ws.onClose((res) => {
console.log('[NetworkManager] Connection closed:', res.code, res.reason);
this._connected = false;
this._connecting = false;
this._stopHeartbeat();
this._emit('disconnected', { code: res.code, reason: res.reason });
// Auto-reconnect if needed
if (this._shouldReconnect && this._reconnectAttempts < this._maxReconnectAttempts) {
this._attemptReconnect();
}
});
} catch (e) {
console.error('[NetworkManager] Failed to create WebSocket:', e);
this._connecting = false;
resolve(false);
}
});
}
/**
* Disconnect from the server.
*/
disconnect() {
this._shouldReconnect = false;
this._stopHeartbeat();
this._clearReconnectTimer();
if (this._ws) {
try {
this._ws.close({});
} catch (e) {
// Ignore close errors
}
this._ws = null;
}
this._connected = false;
this._connecting = false;
this._roomId = null;
this._playerSlot = 0;
}
/**
* Send a message to the server.
* @param {string} type - Message type from NET_MSG.
* @param {object} [data={}] - Message payload.
*/
send(type, data = {}) {
if (!this._connected || !this._ws) {
console.warn('[NetworkManager] Cannot send, not connected');
return;
}
const message = JSON.stringify({
type,
data,
playerId: this._playerId,
roomId: this._roomId,
timestamp: Date.now(),
});
try {
this._ws.send({ data: message });
} catch (e) {
console.error('[NetworkManager] Send error:', e);
}
}
/**
* Create a new room on the server.
*/
createRoom() {
this.send(NET_MSG.CREATE_ROOM, {
playerId: this._playerId,
});
}
/**
* Join an existing room.
* @param {string} roomId - Room ID to join.
*/
joinRoom(roomId) {
this.send(NET_MSG.JOIN_ROOM, {
playerId: this._playerId,
roomId: roomId,
});
}
/**
* Send player input to the server.
* @param {object} input - { direction, firing, x, y }
*/
sendInput(input) {
this.send(NET_MSG.PLAYER_INPUT, input);
}
/**
* Send player state for synchronization.
* @param {object} state - { x, y, direction, hp, alive }
*/
sendState(state) {
this.send(NET_MSG.PLAYER_STATE, state);
}
/**
* Send bullet fire event.
* @param {object} bulletData - { x, y, direction }
*/
sendBulletFire(bulletData) {
this.send(NET_MSG.BULLET_FIRE, bulletData);
}
// ============================================================
// 3v3 Team Methods
// ============================================================
/**
* Create a new team for 3v3 mode.
*/
createTeam() {
this.send(NET_MSG.CREATE_TEAM, {
playerId: this._playerId,
});
}
/**
* Join an existing team by teamId.
* @param {string} teamId - Team ID to join.
*/
joinTeam(teamId) {
this.send(NET_MSG.JOIN_TEAM, {
playerId: this._playerId,
teamId,
});
}
/**
* Leave the current team.
*/
leaveTeam() {
this.send(NET_MSG.LEAVE_TEAM, {
playerId: this._playerId,
});
}
/**
* Toggle ready state in team room.
* @param {boolean} ready - Whether the player is ready.
*/
teamReady(ready) {
this.send(NET_MSG.TEAM_READY, {
playerId: this._playerId,
ready,
});
}
/**
* Start matchmaking (leader only).
*/
startMatch() {
this.send(NET_MSG.MATCH_START, {
playerId: this._playerId,
});
}
/**
* Cancel matchmaking (leader only).
*/
cancelMatch() {
this.send(NET_MSG.MATCH_CANCEL, {
playerId: this._playerId,
});
}
/**
* Kick a player from the team (leader only).
* @param {string} targetPlayerId - Player ID to kick.
*/
kickPlayer(targetPlayerId) {
this.send(NET_MSG.TEAM_KICK, {
playerId: this._playerId,
targetPlayerId,
});
}
/**
* Disband the team (leader only).
*/
disbandTeam() {
this.send(NET_MSG.TEAM_DISBAND, {
playerId: this._playerId,
});
}
/**
* Start solo matchmaking for 3v3.
*/
soloMatch() {
this.send(NET_MSG.SOLO_MATCH, {
playerId: this._playerId,
});
}
/**
* Attempt to reconnect to an ongoing team game.
* @param {string} teamId - Team room ID.
*/
reconnectToTeam(teamId) {
this.send(NET_MSG.RECONNECT, {
teamId,
playerId: this._playerId,
});
}
/**
* Register a handler for a message type.
* @param {string} type - Message type.
* @param {Function} handler - Callback function(data).
* @returns {Function} Unsubscribe function.
*/
on(type, handler) {
if (!this._handlers.has(type)) {
this._handlers.set(type, []);
}
this._handlers.get(type).push(handler);
return () => {
const list = this._handlers.get(type);
if (list) {
const idx = list.indexOf(handler);
if (idx !== -1) list.splice(idx, 1);
}
};
}
/**
* Remove a handler for a message type.
* @param {string} type
* @param {Function} handler
*/
off(type, handler) {
const list = this._handlers.get(type);
if (list) {
const idx = list.indexOf(handler);
if (idx !== -1) list.splice(idx, 1);
}
}
/**
* Remove all handlers.
*/
clearHandlers() {
this._handlers.clear();
}
// ============================================================
// Private Methods
// ============================================================
/**
* Handle incoming WebSocket message.
* @private
*/
_handleMessage(rawData) {
try {
const msg = JSON.parse(rawData);
const { type, data } = msg;
// Handle system messages
if (type === NET_MSG.PONG) {
this._latency = Date.now() - this._lastPingTime;
this._resetHeartbeatTimeout();
return;
}
if (type === NET_MSG.ROOM_CREATED) {
this._roomId = data.roomId;
this._playerSlot = 1; // Creator is player 1
} else if (type === NET_MSG.ROOM_JOINED) {
this._roomId = data.roomId;
this._playerSlot = data.playerSlot || 2;
}
// Emit to registered handlers
this._emit(type, data);
} catch (e) {
console.error('[NetworkManager] Failed to parse message:', e, rawData);
}
}
/**
* Emit an event to registered handlers.
* @private
*/
_emit(type, data) {
const list = this._handlers.get(type);
if (list) {
for (const handler of list) {
try {
handler(data);
} catch (e) {
console.error(`[NetworkManager] Handler error for "${type}":`, e);
}
}
}
}
/**
* Start heartbeat ping/pong.
* @private
*/
_startHeartbeat() {
this._stopHeartbeat();
this._heartbeatInterval = setInterval(() => {
if (this._connected) {
this._lastPingTime = Date.now();
this.send(NET_MSG.PING);
this._startHeartbeatTimeout();
}
}, this._heartbeatMs);
}
/**
* Stop heartbeat.
* @private
*/
_stopHeartbeat() {
if (this._heartbeatInterval) {
clearInterval(this._heartbeatInterval);
this._heartbeatInterval = null;
}
this._resetHeartbeatTimeout();
}
/**
* Start heartbeat timeout (disconnect if no pong received).
* @private
*/
_startHeartbeatTimeout() {
this._resetHeartbeatTimeout();
this._heartbeatTimeout = setTimeout(() => {
console.warn('[NetworkManager] Heartbeat timeout, disconnecting');
this.disconnect();
}, this._heartbeatTimeoutMs);
}
/**
* Reset heartbeat timeout.
* @private
*/
_resetHeartbeatTimeout() {
if (this._heartbeatTimeout) {
clearTimeout(this._heartbeatTimeout);
this._heartbeatTimeout = null;
}
}
/**
* Attempt to reconnect to the server.
* @private
*/
_attemptReconnect() {
this._clearReconnectTimer();
this._reconnectAttempts++;
console.log(`[NetworkManager] Reconnect attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts}`);
this._emit('reconnecting', { attempt: this._reconnectAttempts });
this._reconnectTimer = setTimeout(() => {
this.connect(this._serverUrl);
}, this._reconnectDelay * this._reconnectAttempts);
}
/**
* Clear reconnect timer.
* @private
*/
_clearReconnectTimer() {
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
}
/**
* Generate a unique player ID.
* @private
* @returns {string}
*/
_generatePlayerId() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
let id = 'p_';
for (let i = 0; i < 8; i++) {
id += chars.charAt(Math.floor(Math.random() * chars.length));
}
return id + '_' + Date.now().toString(36);
}
// ============================================================
// Getters
// ============================================================
/** Whether currently connected. */
get connected() {
return this._connected;
}
/** Current room ID. */
get roomId() {
return this._roomId;
}
/** Player slot (1 or 2). */
get playerSlot() {
return this._playerSlot;
}
/** Player unique ID. */
get playerId() {
return this._playerId;
}
/** Current latency in ms. */
get latency() {
return this._latency;
}
/** Whether currently connecting. */
get connecting() {
return this._connecting;
}
}
module.exports = NetworkManager;
+352
View File
@@ -0,0 +1,352 @@
/**
* PaymentManager.js
* Simplified payment manager for monetization-lite.
* Only 3 products: Ad-Free (¥18), Gold Pack (¥6=1000g), Newcomer Pack (¥1=500g).
* Handles WeChat payment, order recovery, and newcomer pack 24h timer.
*/
/** Product definitions. */
const PRODUCTS = {
AD_FREE: {
id: 'ad_free',
price: 18, // ¥18
name: 'Remove Ads (Permanent)',
type: 'permanent',
},
GOLD_PACK: {
id: 'gold_pack',
price: 6, // ¥6
goldAmount: 1000,
name: 'Gold Pack',
type: 'consumable',
},
NEWCOMER_PACK: {
id: 'newcomer_pack',
price: 1, // ¥1
goldAmount: 500,
name: 'Newcomer Pack',
type: 'one_time',
},
};
/** Newcomer pack availability window in milliseconds (24 hours). */
const NEWCOMER_WINDOW_MS = 24 * 60 * 60 * 1000;
class PaymentManager {
constructor() {
this._adFreePurchased = false;
this._newcomerPackPurchased = false;
this._newcomerPackStartTime = 0; // timestamp when user first entered game
this._pendingOrders = [];
this._load();
}
// ============================================================
// Persistence
// ============================================================
/**
* Load payment state from StorageManager.
* @private
*/
_load() {
try {
if (GameGlobal && GameGlobal.storageManager) {
const data = GameGlobal.storageManager.get('payment', null);
if (data) {
this._adFreePurchased = data.adFreePurchased || false;
this._newcomerPackPurchased = data.newcomerPackPurchased || false;
this._newcomerPackStartTime = data.newcomerPackStartTime || 0;
this._pendingOrders = data.pendingOrders || [];
}
// Initialize newcomer pack timer on first load
if (this._newcomerPackStartTime === 0) {
this._newcomerPackStartTime = Date.now();
this._save();
}
}
} catch (e) {
console.warn('[PaymentManager] Failed to load payment data:', e);
}
// Try to recover any pending orders
this._recoverPendingOrders();
}
/**
* Save payment state to StorageManager.
* @private
*/
_save() {
try {
if (GameGlobal && GameGlobal.storageManager) {
GameGlobal.storageManager.set('payment', {
adFreePurchased: this._adFreePurchased,
newcomerPackPurchased: this._newcomerPackPurchased,
newcomerPackStartTime: this._newcomerPackStartTime,
pendingOrders: this._pendingOrders,
});
}
} catch (e) {
console.warn('[PaymentManager] Failed to save payment data:', e);
}
}
// ============================================================
// Purchase API
// ============================================================
/**
* Purchase the ad-free privilege (¥18, permanent).
* @param {Function} callback - Called with { success: boolean, error?: string }.
*/
purchaseAdFree(callback) {
if (this._adFreePurchased) {
if (callback) callback({ success: false, error: 'Already purchased' });
return;
}
this._requestPayment(PRODUCTS.AD_FREE, (result) => {
if (result.success) {
this._adFreePurchased = true;
this._save();
// Enable ad-free in AdManager
if (GameGlobal.adManager) {
GameGlobal.adManager.enableAdFree();
}
// Emit event
this._emitPurchaseEvent('ad_free');
}
if (callback) callback(result);
});
}
/**
* Purchase a gold pack (¥6 = 1000 gold).
* @param {Function} callback - Called with { success: boolean, error?: string }.
*/
purchaseGoldPack(callback) {
this._requestPayment(PRODUCTS.GOLD_PACK, (result) => {
if (result.success) {
// Award gold
if (GameGlobal.currencyManager) {
GameGlobal.currencyManager.addGold(PRODUCTS.GOLD_PACK.goldAmount);
}
this._emitPurchaseEvent('gold_pack');
}
if (callback) callback(result);
});
}
/**
* Purchase the newcomer pack (¥1 = 500 gold, one-time only).
* @param {Function} callback - Called with { success: boolean, error?: string }.
*/
purchaseNewcomerPack(callback) {
if (this._newcomerPackPurchased) {
if (callback) callback({ success: false, error: 'Already purchased' });
return;
}
if (!this.isNewcomerPackAvailable()) {
if (callback) callback({ success: false, error: 'Newcomer pack expired' });
return;
}
this._requestPayment(PRODUCTS.NEWCOMER_PACK, (result) => {
if (result.success) {
this._newcomerPackPurchased = true;
this._save();
// Award gold
if (GameGlobal.currencyManager) {
GameGlobal.currencyManager.addGold(PRODUCTS.NEWCOMER_PACK.goldAmount);
}
this._emitPurchaseEvent('newcomer_pack');
}
if (callback) callback(result);
});
}
// ============================================================
// Status Queries
// ============================================================
/**
* Whether ad-free has been purchased.
* @returns {boolean}
*/
isAdFreePurchased() {
return this._adFreePurchased;
}
/**
* Whether the newcomer pack is still available (within 24h and not purchased).
* @returns {boolean}
*/
isNewcomerPackAvailable() {
if (this._newcomerPackPurchased) return false;
const elapsed = Date.now() - this._newcomerPackStartTime;
return elapsed < NEWCOMER_WINDOW_MS;
}
/**
* Get remaining time for newcomer pack in milliseconds.
* @returns {number} Remaining ms, or 0 if expired.
*/
getNewcomerPackRemainingMs() {
if (this._newcomerPackPurchased) return 0;
const remaining = NEWCOMER_WINDOW_MS - (Date.now() - this._newcomerPackStartTime);
return Math.max(0, remaining);
}
/**
* Get product definitions.
* @returns {object}
*/
getProducts() {
return PRODUCTS;
}
// ============================================================
// WeChat Payment
// ============================================================
/**
* Request payment via WeChat Midas payment.
* @param {object} product - Product definition.
* @param {Function} callback - Called with { success: boolean, error?: string }.
* @private
*/
_requestPayment(product, callback) {
// Add to pending orders for recovery
const orderId = `${product.id}_${Date.now()}`;
this._pendingOrders.push({ orderId, productId: product.id, timestamp: Date.now() });
this._save();
try {
if (typeof wx === 'undefined' || typeof wx.requestMidasPayment !== 'function') {
// Dev environment: simulate success
console.log(`[PaymentManager] Dev mode: simulating purchase of ${product.id}`);
this._removePendingOrder(orderId);
if (callback) callback({ success: true });
return;
}
wx.requestMidasPayment({
mode: 'game',
env: 0, // 0 = production, 1 = sandbox
offerId: '', // Replace with actual offer ID
currencyType: 'CNY',
buyQuantity: product.price * 10, // Midas uses 1/10 yuan units
success: () => {
console.log(`[PaymentManager] Purchase successful: ${product.id}`);
this._removePendingOrder(orderId);
if (callback) callback({ success: true });
},
fail: (err) => {
console.warn(`[PaymentManager] Purchase failed: ${product.id}`, err);
this._removePendingOrder(orderId);
if (callback) callback({ success: false, error: err.errMsg || 'Payment failed' });
},
});
} catch (e) {
console.warn('[PaymentManager] Payment request error:', e);
this._removePendingOrder(orderId);
if (callback) callback({ success: false, error: e.message });
}
}
/**
* Remove a pending order after completion.
* @param {string} orderId
* @private
*/
_removePendingOrder(orderId) {
this._pendingOrders = this._pendingOrders.filter(o => o.orderId !== orderId);
this._save();
}
/**
* Attempt to recover pending orders (e.g., after network interruption).
* @private
*/
_recoverPendingOrders() {
if (this._pendingOrders.length === 0) return;
// Remove orders older than 1 hour (stale)
const oneHourAgo = Date.now() - 60 * 60 * 1000;
this._pendingOrders = this._pendingOrders.filter(o => o.timestamp > oneHourAgo);
this._save();
// In production, query server for order status and deliver items
// For now, just log
if (this._pendingOrders.length > 0) {
console.log(`[PaymentManager] ${this._pendingOrders.length} pending orders to recover`);
}
}
/**
* Emit a purchase completed event.
* @param {string} productId
* @private
*/
_emitPurchaseEvent(productId) {
try {
if (GameGlobal && GameGlobal.eventBus) {
GameGlobal.eventBus.emit('purchase:completed', { productId });
}
} catch (e) {}
}
// ============================================================
// Cloud Sync
// ============================================================
/**
* Get payment data for cloud sync.
* @returns {object}
*/
getCloudSyncData() {
return {
adFreePurchased: this._adFreePurchased,
newcomerPackPurchased: this._newcomerPackPurchased,
newcomerPackStartTime: this._newcomerPackStartTime,
};
}
/**
* Restore payment data from cloud.
* @param {object} cloudData
*/
restoreFromCloud(cloudData) {
if (!cloudData) return;
let changed = false;
// Ad-free is permanent — if cloud says purchased, trust it
if (cloudData.adFreePurchased && !this._adFreePurchased) {
this._adFreePurchased = true;
if (GameGlobal.adManager) {
GameGlobal.adManager.enableAdFree();
}
changed = true;
}
if (cloudData.newcomerPackPurchased && !this._newcomerPackPurchased) {
this._newcomerPackPurchased = true;
changed = true;
}
if (changed) {
this._save();
}
}
}
PaymentManager.PRODUCTS = PRODUCTS;
module.exports = PaymentManager;
+6
View File
@@ -0,0 +1,6 @@
// PromotionManager - DEPRECATED (removed in monetization-lite)
// This file is intentionally empty. The promotion system has been removed.
class PromotionManager {
constructor() {}
}
module.exports = PromotionManager;
+94
View File
@@ -0,0 +1,94 @@
/**
* ResourceManager.js
* Handles preloading of image resources using wx.createImage.
* Provides progress callback and cached access to loaded images.
*/
class ResourceManager {
constructor() {
/** @type {Map<string, Image>} */
this._cache = new Map();
this._totalAssets = 0;
this._loadedAssets = 0;
}
/**
* Preload a list of image assets.
* @param {Array<{key: string, src: string}>} assetList - Assets to load.
* @param {Function} [onProgress] - Called with (loaded, total) on each load.
* @returns {Promise<void>} Resolves when all assets are loaded.
*/
loadImages(assetList, onProgress) {
this._totalAssets = assetList.length;
this._loadedAssets = 0;
if (this._totalAssets === 0) {
return Promise.resolve();
}
const promises = assetList.map(({ key, src }) => {
return new Promise((resolve, reject) => {
// If already cached, skip
if (this._cache.has(key)) {
this._loadedAssets++;
if (onProgress) onProgress(this._loadedAssets, this._totalAssets);
resolve();
return;
}
const img = wx.createImage();
img.onload = () => {
this._cache.set(key, img);
this._loadedAssets++;
if (onProgress) onProgress(this._loadedAssets, this._totalAssets);
resolve();
};
img.onerror = (err) => {
console.warn(`[ResourceManager] Failed to load: ${src}`, err);
// Resolve anyway so other assets continue loading
this._loadedAssets++;
if (onProgress) onProgress(this._loadedAssets, this._totalAssets);
resolve();
};
img.src = src;
});
});
return Promise.all(promises).then(() => {});
}
/**
* Get a loaded image by key.
* @param {string} key
* @returns {Image|null}
*/
getImage(key) {
return this._cache.get(key) || null;
}
/**
* Check if an image is loaded.
* @param {string} key
* @returns {boolean}
*/
hasImage(key) {
return this._cache.has(key);
}
/**
* Clear all cached images.
*/
clear() {
this._cache.clear();
this._totalAssets = 0;
this._loadedAssets = 0;
}
/** Current loading progress (0 to 1). */
get progress() {
if (this._totalAssets === 0) return 1;
return this._loadedAssets / this._totalAssets;
}
}
module.exports = ResourceManager;
+99
View File
@@ -0,0 +1,99 @@
/**
* SceneManager.js
* Manages scene registration, switching, and lifecycle (enter/exit/update/render).
*/
class SceneManager {
constructor() {
/** @type {Map<string, object>} Registered scene instances */
this._scenes = new Map();
/** @type {object|null} Current active scene */
this._currentScene = null;
/** @type {string|null} Current scene name */
this._currentName = null;
/** @type {boolean} Whether a transition is in progress */
this._transitioning = false;
}
/**
* Register a scene.
* A scene object should implement: enter(params), exit(), update(dt), render(ctx).
* @param {string} name - Unique scene name.
* @param {object} scene - Scene instance.
*/
register(name, scene) {
this._scenes.set(name, scene);
}
/**
* Switch to a different scene.
* @param {string} name - Target scene name.
* @param {object} [params] - Optional parameters passed to the new scene's enter().
*/
switchTo(name, params) {
if (this._transitioning) return;
if (!this._scenes.has(name)) {
console.error(`[SceneManager] Scene "${name}" not registered.`);
return;
}
this._transitioning = true;
// Exit current scene
if (this._currentScene && typeof this._currentScene.exit === 'function') {
this._currentScene.exit();
}
// Enter new scene
this._currentName = name;
this._currentScene = this._scenes.get(name);
if (typeof this._currentScene.enter === 'function') {
this._currentScene.enter(params || {});
}
this._transitioning = false;
}
/**
* Update the current scene.
* @param {number} dt - Delta time in seconds.
*/
update(dt) {
if (this._currentScene && typeof this._currentScene.update === 'function') {
this._currentScene.update(dt);
}
}
/**
* Render the current scene.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (this._currentScene && typeof this._currentScene.render === 'function') {
this._currentScene.render(ctx);
}
}
/**
* Forward touch events to the current scene.
* @param {string} eventType - 'touchstart' | 'touchmove' | 'touchend'
* @param {TouchEvent} e
*/
handleTouch(eventType, e) {
if (this._currentScene && typeof this._currentScene.handleTouch === 'function') {
this._currentScene.handleTouch(eventType, e);
}
}
/** Get the current scene name. */
get currentName() {
return this._currentName;
}
/** Get the current scene instance. */
get currentScene() {
return this._currentScene;
}
}
module.exports = SceneManager;
+177
View File
@@ -0,0 +1,177 @@
/**
* ShareManager.js
* Minimal share manager - only basic share functionality.
* Social fission features have been removed in monetization-lite.
*/
class ShareManager {
constructor() {
// Default share content
this._shareContent = {
title: '坦克大战 - 一起来战斗吧!',
imageUrl: '',
query: '',
};
// Register share menu and callback ONCE at startup.
// The callback reads this._shareContent dynamically so it always
// returns the latest share data without needing re-registration.
try {
if (typeof wx !== 'undefined') {
if (wx.showShareMenu) {
wx.showShareMenu({
withShareTicket: true,
menus: ['shareAppMessage'],
});
}
if (wx.onShareAppMessage) {
wx.onShareAppMessage(() => {
console.log('[ShareManager] onShareAppMessage callback, query:', this._shareContent.query);
return {
title: this._shareContent.title || '坦克大战 - 一起来战斗吧!',
imageUrl: this._shareContent.imageUrl || '',
query: this._shareContent.query || '',
};
});
}
}
} catch (e) {
console.warn('[ShareManager] constructor share setup failed:', e);
}
}
/**
* Update open data for friend ranking.
* @param {number} score
* @param {number} level
*/
updateOpenData(score, level) {
try {
if (typeof wx !== 'undefined' && wx.setUserCloudStorage) {
wx.setUserCloudStorage({
KVDataList: [
{ key: 'score', value: String(score) },
{ key: 'level', value: String(level) },
],
});
}
} catch (e) {
console.warn('[ShareManager] updateOpenData failed:', e);
}
}
/**
* Re-register the onShareAppMessage callback so the latest
* this._shareContent is captured. Called internally whenever
* share content changes.
*/
_refreshShareCallback() {
try {
if (typeof wx !== 'undefined' && wx.onShareAppMessage) {
wx.onShareAppMessage(() => {
console.log('[ShareManager] onShareAppMessage callback fired, query:', this._shareContent.query);
return {
title: this._shareContent.title || '坦克大战 - 一起来战斗吧!',
imageUrl: this._shareContent.imageUrl || '',
query: this._shareContent.query || '',
};
});
}
} catch (e) {
console.warn('[ShareManager] _refreshShareCallback failed:', e);
}
}
/**
* Set share content for the passive share callback (right-corner ··· menu).
* Also re-registers the onShareAppMessage callback to guarantee the
* latest content is used when the user taps the share button.
* @param {object} opts - { title, imageUrl, query }
*/
setShareContent(opts) {
this._shareContent = opts || {};
console.log('[ShareManager] Share content updated:', JSON.stringify(this._shareContent));
// Re-register callback to ensure WeChat picks up the new content
this._refreshShareCallback();
}
/**
* Trigger a share action (e.g. team invite).
* MUST be called within a user-initiated touch event call stack so that
* wx.shareAppMessage() is allowed by WeChat policy.
* Also updates the passive share callback as a fallback for the ··· menu.
* @param {object} opts - { title, imageUrl, query }
*/
triggerShare(opts) {
const data = opts || {};
// Update passive share callback (right-corner ··· menu fallback)
this.setShareContent(data);
// Directly invoke wx.shareAppMessage() to open the friend-picker panel.
// This is permitted because triggerShare is called from a touchstart handler.
try {
if (typeof wx !== 'undefined' && wx.shareAppMessage) {
console.log('[ShareManager] Calling wx.shareAppMessage with query:', data.query);
wx.shareAppMessage({
title: data.title || '',
imageUrl: data.imageUrl || '',
query: data.query || '',
});
}
} catch (e) {
console.warn('[ShareManager] wx.shareAppMessage failed, falling back to toast:', e);
// Fallback: prompt user to use the ··· menu
try {
if (typeof wx !== 'undefined' && wx.showToast) {
wx.showToast({
title: '请点击右上角「···」转发给好友',
icon: 'none',
duration: 2500,
});
}
} catch (e2) {
console.warn('[ShareManager] triggerShare fallback failed:', e2);
}
}
}
/**
* Reset share content to default (clear team invite data).
* Called when leaving the team room.
*/
resetShareContent() {
this._shareContent = {
title: '坦克大战 - 一起来战斗吧!',
imageUrl: '',
query: '',
};
console.log('[ShareManager] Share content reset to default');
this._refreshShareCallback();
}
/**
* Share a challenge to friends.
* Sets the share content and prompts the user to share via the menu.
* @param {number} level
* @param {number} score
*/
shareChallenge(level, score) {
this.setShareContent({
title: `我在坦克大战第${level}关拿了${score}分!你能超过我吗?`,
imageUrl: '',
query: '',
});
try {
if (typeof wx !== 'undefined' && wx.showToast) {
wx.showToast({
title: '请点击右上角「···」分享给好友',
icon: 'none',
duration: 2500,
});
}
} catch (e) {
console.warn('[ShareManager] shareChallenge failed:', e);
}
}
}
module.exports = ShareManager;
+275
View File
@@ -0,0 +1,275 @@
/**
* SkinManager.js
* Manages tank skin purchases, equipping, and persistence.
* Skins are cosmetic-only color schemes purchased with gold.
*/
/** Skin definitions with id, name, cost, and color scheme. */
const SKINS = {
default: {
id: 'default',
nameKey: 'skin.default',
cost: 0,
colors: null, // uses default tank color
preview: '#FFD700',
},
arctic: {
id: 'arctic',
nameKey: 'skin.arctic',
cost: 500,
colors: { body: '#B0E0E6', turret: '#5F9EA0', track: '#2F4F4F' },
preview: '#B0E0E6',
},
inferno: {
id: 'inferno',
nameKey: 'skin.inferno',
cost: 800,
colors: { body: '#FF4500', turret: '#8B0000', track: '#2F0000' },
preview: '#FF4500',
},
phantom: {
id: 'phantom',
nameKey: 'skin.phantom',
cost: 1200,
colors: { body: '#9370DB', turret: '#4B0082', track: '#1C0033' },
preview: '#9370DB',
},
jungle: {
id: 'jungle',
nameKey: 'skin.jungle',
cost: 1000,
colors: { body: '#3CB371', turret: '#006400', track: '#002200' },
preview: '#3CB371',
},
neon: {
id: 'neon',
nameKey: 'skin.neon',
cost: 2000,
colors: { body: '#00FF7F', turret: '#00CED1', track: '#008B8B' },
preview: '#00FF7F',
},
shadow: {
id: 'shadow',
nameKey: 'skin.shadow',
cost: 3000,
colors: { body: '#2C2C2C', turret: '#1A1A1A', track: '#0D0D0D' },
preview: '#2C2C2C',
},
royal: {
id: 'royal',
nameKey: 'skin.royal',
cost: 5000,
colors: { body: '#FFD700', turret: '#DAA520', track: '#B8860B' },
preview: '#FFD700',
},
};
/** Ordered list of skin IDs for display. */
const SKIN_ORDER = ['default', 'arctic', 'inferno', 'jungle', 'phantom', 'neon', 'shadow', 'royal'];
class SkinManager {
constructor() {
/** @type {Set<string>} Unlocked skin IDs. */
this._unlocked = new Set(['default']);
/** @type {string} Currently equipped skin ID. */
this._equipped = 'default';
this._load();
}
// ============================================================
// Persistence
// ============================================================
/** @private */
_load() {
try {
if (GameGlobal && GameGlobal.storageManager) {
const data = GameGlobal.storageManager.get('skins', null);
if (data) {
this._unlocked = new Set(data.unlocked || ['default']);
this._equipped = data.equipped || 'default';
// Ensure default is always unlocked
this._unlocked.add('default');
}
}
} catch (e) {
console.warn('[SkinManager] Failed to load skin data:', e);
}
}
/** @private */
_save() {
try {
if (GameGlobal && GameGlobal.storageManager) {
GameGlobal.storageManager.set('skins', {
unlocked: Array.from(this._unlocked),
equipped: this._equipped,
});
}
} catch (e) {
console.warn('[SkinManager] Failed to save skin data:', e);
}
}
// ============================================================
// Queries
// ============================================================
/**
* Get all skin definitions in display order.
* @returns {Array<object>}
*/
getAllSkins() {
return SKIN_ORDER.map(id => SKINS[id]);
}
/**
* Check if a skin is unlocked.
* @param {string} skinId
* @returns {boolean}
*/
isUnlocked(skinId) {
return this._unlocked.has(skinId);
}
/**
* Get the currently equipped skin ID.
* @returns {string}
*/
getEquippedSkinId() {
return this._equipped;
}
/**
* Get the color scheme for the currently equipped skin.
* @returns {object|null} { body, turret, track } or null for default.
*/
getCurrentSkinColors() {
const skin = SKINS[this._equipped];
if (!skin) return null;
return skin.colors; // null for default skin
}
/**
* Get skin definition by ID.
* @param {string} skinId
* @returns {object|null}
*/
getSkin(skinId) {
return SKINS[skinId] || null;
}
// ============================================================
// Actions
// ============================================================
/**
* Purchase a skin with gold.
* @param {string} skinId
* @returns {{ success: boolean, error?: string }}
*/
purchaseSkin(skinId) {
const skin = SKINS[skinId];
if (!skin) {
return { success: false, error: 'Invalid skin' };
}
if (this._unlocked.has(skinId)) {
return { success: false, error: 'Already unlocked' };
}
const cm = GameGlobal.currencyManager;
if (!cm || !cm.hasGold(skin.cost)) {
return { success: false, error: 'Insufficient gold' };
}
const spent = cm.spendGold(skin.cost);
if (!spent) {
return { success: false, error: 'Insufficient gold' };
}
this._unlocked.add(skinId);
this._save();
console.log(`[SkinManager] Purchased skin: ${skinId} for ${skin.cost} gold`);
// Emit event
try {
if (GameGlobal.eventBus) {
GameGlobal.eventBus.emit('skin:purchased', { id: skinId, cost: skin.cost });
}
} catch (e) {}
return { success: true };
}
/**
* Equip an unlocked skin.
* @param {string} skinId
* @returns {{ success: boolean, error?: string }}
*/
equipSkin(skinId) {
if (!SKINS[skinId]) {
return { success: false, error: 'Invalid skin' };
}
if (!this._unlocked.has(skinId)) {
return { success: false, error: 'Not unlocked' };
}
this._equipped = skinId;
this._save();
console.log(`[SkinManager] Equipped skin: ${skinId}`);
// Emit event
try {
if (GameGlobal.eventBus) {
GameGlobal.eventBus.emit('skin:equipped', { id: skinId });
}
} catch (e) {}
return { success: true };
}
// ============================================================
// Cloud Sync
// ============================================================
/**
* Get skin data for cloud sync.
* @returns {object}
*/
getCloudSyncData() {
return {
unlocked: Array.from(this._unlocked),
equipped: this._equipped,
};
}
/**
* Restore skin data from cloud (merge: keep all unlocked).
* @param {object} cloudData
*/
restoreFromCloud(cloudData) {
if (!cloudData) return;
if (cloudData.unlocked) {
for (const id of cloudData.unlocked) {
if (SKINS[id]) {
this._unlocked.add(id);
}
}
}
if (cloudData.equipped && SKINS[cloudData.equipped] && this._unlocked.has(cloudData.equipped)) {
this._equipped = cloudData.equipped;
}
this._save();
}
}
module.exports = SkinManager;
+158
View File
@@ -0,0 +1,158 @@
/**
* SpawnManager.js
* Manages enemy tank spawning: timing, spawn points, composition, and limits.
*/
const EnemyTank = require('../entities/EnemyTank');
const {
TANK_TYPE,
MAX_ENEMIES_ON_SCREEN,
ENEMY_SPAWN_INTERVAL,
TILE_SIZE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
GRID_COLS,
} = require('../base/GameGlobal');
class SpawnManager {
constructor() {
/** @type {Array<{col: number, row: number}>} */
this._spawnPoints = [];
this._currentSpawnIndex = 0;
// Spawn queue
this._spawnQueue = []; // array of TANK_TYPE values
this._spawnTimer = 0;
this._spawnInterval = ENEMY_SPAWN_INTERVAL;
this._totalSpawned = 0;
this._totalEnemies = 0;
// Level info
this._levelNum = 1;
// Power-up enemy indices (which enemies drop power-ups)
this._powerUpIndices = new Set();
}
/**
* Initialize for a new level.
* @param {object} levelData - Level configuration from LevelData.
*/
init(levelData) {
this._spawnPoints = levelData.spawnPoints || [
{ col: 0, row: 0 },
{ col: Math.floor(GRID_COLS / 2), row: 0 },
{ col: GRID_COLS - 1, row: 0 },
];
this._currentSpawnIndex = 0;
this._spawnTimer = 0;
this._totalSpawned = 0;
this._levelNum = levelData.id || 1;
this._speedMultiplier = levelData.speedMultiplier || 1;
// Build spawn queue from composition
this._spawnQueue = [];
const comp = levelData.enemies.composition;
this._totalEnemies = levelData.enemies.total;
// Add enemies by type
for (let i = 0; i < (comp.boss || 0); i++) this._spawnQueue.push(TANK_TYPE.ENEMY_BOSS);
for (let i = 0; i < (comp.armor || 0); i++) this._spawnQueue.push(TANK_TYPE.ENEMY_ARMOR);
for (let i = 0; i < (comp.fast || 0); i++) this._spawnQueue.push(TANK_TYPE.ENEMY_FAST);
for (let i = 0; i < (comp.normal || 0); i++) this._spawnQueue.push(TANK_TYPE.ENEMY_NORMAL);
// Shuffle the queue for variety
this._shuffleArray(this._spawnQueue);
// Determine which enemies drop power-ups (roughly every 4-5 enemies)
this._powerUpIndices.clear();
const numPowerUps = Math.max(1, Math.floor(this._totalEnemies / 5));
const indices = new Set();
while (indices.size < numPowerUps) {
indices.add(Math.floor(Math.random() * this._totalEnemies));
}
this._powerUpIndices = indices;
// Spawn first batch immediately
this._spawnTimer = this._spawnInterval;
}
/**
* Update spawn timer and spawn enemies as needed.
* @param {number} dt - Delta time in seconds.
* @param {Array<EnemyTank>} activeEnemies - Currently alive enemies.
* @returns {EnemyTank|null} Newly spawned enemy, or null.
*/
update(dt, activeEnemies) {
if (this._spawnQueue.length === 0) return null;
const aliveCount = activeEnemies.filter((e) => e.alive).length;
if (aliveCount >= MAX_ENEMIES_ON_SCREEN) return null;
this._spawnTimer += dt * 1000;
if (this._spawnTimer < this._spawnInterval) return null;
this._spawnTimer = 0;
return this._spawnNext();
}
/**
* Spawn the next enemy from the queue.
* @private
* @returns {EnemyTank|null}
*/
_spawnNext() {
if (this._spawnQueue.length === 0) return null;
const type = this._spawnQueue.shift();
const spawnPoint = this._spawnPoints[this._currentSpawnIndex % this._spawnPoints.length];
this._currentSpawnIndex++;
const hasPowerUp = this._powerUpIndices.has(this._totalSpawned);
this._totalSpawned++;
const enemy = new EnemyTank({
type,
col: spawnPoint.col,
row: spawnPoint.row,
levelNum: this._levelNum,
hasPowerUp,
speedMultiplier: this._speedMultiplier,
});
return enemy;
}
/**
* Fisher-Yates shuffle.
* @private
*/
_shuffleArray(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
}
/** Number of enemies remaining to spawn. */
get remainingToSpawn() {
return this._spawnQueue.length;
}
/** Total enemies for this level. */
get totalEnemies() {
return this._totalEnemies;
}
/** Total spawned so far. */
get totalSpawned() {
return this._totalSpawned;
}
/** Whether all enemies have been spawned. */
get allSpawned() {
return this._spawnQueue.length === 0;
}
}
module.exports = SpawnManager;
+6
View File
@@ -0,0 +1,6 @@
// StaminaManager - DEPRECATED (removed in monetization-lite)
// This file is intentionally empty. The stamina system has been removed.
class StaminaManager {
constructor() {}
}
module.exports = StaminaManager;
+249
View File
@@ -0,0 +1,249 @@
/**
* StorageManager.js
* Handles local data persistence using wx.setStorageSync/getStorageSync.
* Manages game save data, settings, and high scores.
*/
class StorageManager {
constructor() {
this._prefix = 'tankwar_';
}
// ============================================================
// Generic Storage
// ============================================================
/**
* Save a value to local storage.
* @param {string} key
* @param {*} value - Will be JSON-serialized.
*/
set(key, value) {
try {
wx.setStorageSync(this._prefix + key, JSON.stringify(value));
} catch (e) {
console.warn(`[StorageManager] Failed to save "${key}":`, e);
}
}
/**
* Load a value from local storage.
* @param {string} key
* @param {*} [defaultValue=null]
* @returns {*} Parsed value, or defaultValue if not found.
*/
get(key, defaultValue = null) {
try {
const raw = wx.getStorageSync(this._prefix + key);
if (raw === '' || raw === undefined || raw === null) return defaultValue;
return JSON.parse(raw);
} catch (e) {
console.warn(`[StorageManager] Failed to load "${key}":`, e);
return defaultValue;
}
}
/**
* Remove a value from local storage.
* @param {string} key
*/
remove(key) {
try {
wx.removeStorageSync(this._prefix + key);
} catch (e) {}
}
// ============================================================
// Game Progress
// ============================================================
/**
* Save game progress.
* @param {object} progress
* @param {number} progress.currentLevel
* @param {number} progress.lives
* @param {number} progress.fireLevel
* @param {string} progress.mode
*/
saveProgress(progress) {
this.set('progress', progress);
}
/**
* Load game progress.
* @returns {object|null}
*/
loadProgress() {
return this.get('progress', null);
}
/**
* Clear saved progress.
*/
clearProgress() {
this.remove('progress');
}
// ============================================================
// High Scores
// ============================================================
/**
* Get the high score for a mode.
* @param {string} mode - Game mode.
* @returns {number}
*/
getHighScore(mode) {
const scores = this.get('highscores', {});
return scores[mode] || 0;
}
/**
* Update high score if the new score is higher.
* @param {string} mode
* @param {number} score
* @returns {boolean} Whether a new high score was set.
*/
updateHighScore(mode, score) {
const scores = this.get('highscores', {});
if (score > (scores[mode] || 0)) {
scores[mode] = score;
this.set('highscores', scores);
return true;
}
return false;
}
/**
* Get the highest level reached.
* @returns {number}
*/
getHighestLevel() {
return this.get('highest_level', 0);
}
/**
* Update highest level if new level is higher.
* @param {number} level
*/
updateHighestLevel(level) {
const current = this.getHighestLevel();
if (level > current) {
this.set('highest_level', level);
}
}
// ============================================================
// Settings
// ============================================================
/**
* Save game settings.
* @param {object} settings
*/
saveSettings(settings) {
this.set('settings', settings);
}
/**
* Load game settings.
* @returns {object}
*/
loadSettings() {
return this.get('settings', {
soundEnabled: true,
musicEnabled: true,
vibrationEnabled: true,
});
}
// ============================================================
// Purchases & Unlocks
// ============================================================
/**
* Record a purchase.
* @param {string} itemId
*/
recordPurchase(itemId) {
const purchases = this.get('purchases', []);
if (!purchases.includes(itemId)) {
purchases.push(itemId);
this.set('purchases', purchases);
}
}
/**
* Check if an item has been purchased.
* @param {string} itemId
* @returns {boolean}
*/
hasPurchased(itemId) {
const purchases = this.get('purchases', []);
return purchases.includes(itemId);
}
// ============================================================
// First-time flags
// ============================================================
/**
* Check if this is the first time playing.
* @returns {boolean}
*/
isFirstPlay() {
return !this.get('has_played', false);
}
/**
* Mark that the player has played.
*/
markPlayed() {
this.set('has_played', true);
}
// ============================================================
// Cloud Sync Helpers
// ============================================================
/**
* Get data to sync to cloud.
* @returns {object}
*/
getCloudSyncData() {
return {
highscores: this.get('highscores', {}),
highest_level: this.getHighestLevel(),
purchases: this.get('purchases', []),
};
}
/**
* Restore data from cloud.
* @param {object} cloudData
*/
restoreFromCloud(cloudData) {
if (cloudData.highscores) {
const local = this.get('highscores', {});
// Merge: keep the higher score
for (const [mode, score] of Object.entries(cloudData.highscores)) {
if (score > (local[mode] || 0)) {
local[mode] = score;
}
}
this.set('highscores', local);
}
if (cloudData.highest_level) {
this.updateHighestLevel(cloudData.highest_level);
}
if (cloudData.purchases) {
const local = this.get('purchases', []);
const merged = [...new Set([...local, ...cloudData.purchases])];
this.set('purchases', merged);
}
}
}
module.exports = StorageManager;
+13
View File
@@ -0,0 +1,13 @@
// BattlePassScene - DEPRECATED (removed in monetization-lite)
const { SCENE } = require('../base/GameGlobal');
const BattlePassScene = {
enter() {
// Redirect to menu since battle pass is removed
GameGlobal.sceneManager.switchTo(SCENE.MENU);
},
exit() {},
update() {},
render() {},
handleTouch() {},
};
module.exports = BattlePassScene;
+249
View File
@@ -0,0 +1,249 @@
/**
* BuffSelectScene.js
* Pre-game buff selection screen.
* Allows players to purchase Shield (100g) and/or Double Fire (150g)
* before entering a game level.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const BuffManager = require('../managers/BuffManager');
const BUFF_TYPE = BuffManager.BUFF_TYPE;
const BUFF_COST = BuffManager.BUFF_COST;
// Layout constants
const CARD_W = Math.min(SCREEN_WIDTH * 0.38, 160);
const CARD_H = Math.min(SCREEN_HEIGHT * 0.3, 140);
const CARD_GAP = 20;
const CARD_Y = SCREEN_HEIGHT * 0.3;
const BuffSelectScene = {
_gameParams: null, // params to pass to GameScene
_buttons: {},
_buffManager: null,
enter(params) {
this._gameParams = params || {};
this._buffManager = GameGlobal.buffManager;
this._buttons = {};
// Clear any previous buffs
if (this._buffManager) {
this._buffManager.clearBuffs();
}
this._calculateLayout();
},
exit() {},
_calculateLayout() {
const cx = SCREEN_WIDTH / 2;
// Two buff cards side by side
const card1X = cx - CARD_W - CARD_GAP / 2;
const card2X = cx + CARD_GAP / 2;
this._buttons = {
shield: { x: card1X, y: CARD_Y, w: CARD_W, h: CARD_H, type: BUFF_TYPE.SHIELD },
doubleFire: { x: card2X, y: CARD_Y, w: CARD_W, h: CARD_H, type: BUFF_TYPE.DOUBLE_FIRE },
};
// Start/Skip button
const btnW = Math.min(SCREEN_WIDTH * 0.5, 200);
const btnH = 42;
const btnY = CARD_Y + CARD_H + 30;
this._buttons.start = { x: cx - btnW / 2, y: btnY, w: btnW, h: btnH };
this._buttons.skip = { x: cx - btnW / 2, y: btnY + btnH + 12, w: btnW, h: btnH };
},
update(dt) {},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('buff.title') || 'Pre-Game Buffs', SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.12);
// Gold balance
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 16px Arial';
ctx.fillText(`🪙 ${gold}`, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.2);
// Render buff cards
this._renderBuffCard(ctx, this._buttons.shield,
t('buff.shield') || '🛡️ Shield',
t('buff.shieldDesc') || 'Start with a shield layer',
BUFF_COST[BUFF_TYPE.SHIELD],
BUFF_TYPE.SHIELD
);
this._renderBuffCard(ctx, this._buttons.doubleFire,
t('buff.doubleFire') || '🔥 Double Fire',
t('buff.doubleFireDesc') || '2x bullet power for 10s',
BUFF_COST[BUFF_TYPE.DOUBLE_FIRE],
BUFF_TYPE.DOUBLE_FIRE
);
// Start button (if any buff purchased)
const hasBuffs = this._buffManager && this._buffManager.getActiveBuffs().length > 0;
if (hasBuffs) {
this._renderButton(ctx, this._buttons.start, t('buff.start') || 'Start Game', '#4CAF50');
}
// Skip button
this._renderButton(ctx, this._buttons.skip, t('buff.skip') || 'Skip →', '#666666');
},
_renderBuffCard(ctx, rect, title, desc, cost, buffType) {
if (!rect) return;
const purchased = this._buffManager && this._buffManager.hasBuff(buffType);
const canAfford = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(cost);
// Card background
ctx.fillStyle = purchased ? 'rgba(76, 175, 80, 0.3)' : 'rgba(255,255,255,0.05)';
ctx.strokeStyle = purchased ? '#4CAF50' : '#444444';
ctx.lineWidth = 2;
// Rounded rect
const r = 10;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
const cx = rect.x + rect.w / 2;
// Title
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.fillText(title, cx, rect.y + 30);
// Description
ctx.fillStyle = '#AAAAAA';
ctx.font = '11px Arial';
ctx.fillText(desc, cx, rect.y + 55);
// Cost or status
if (purchased) {
ctx.fillStyle = '#4CAF50';
ctx.font = 'bold 14px Arial';
ctx.fillText(t('buff.purchased') || '✓ Purchased', cx, rect.y + rect.h - 25);
} else {
ctx.fillStyle = canAfford ? '#FFD700' : '#FF4444';
ctx.font = 'bold 14px Arial';
ctx.fillText(`🪙 ${cost}`, cx, rect.y + rect.h - 25);
}
},
_renderButton(ctx, rect, label, color) {
if (!rect) return;
ctx.fillStyle = color;
ctx.strokeStyle = '#333333';
ctx.lineWidth = 1;
const r = 6;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
_startGame() {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.GAME)) {
const GameScene = require('./GameScene');
sm.register(SCENE.GAME, GameScene);
}
sm.switchTo(SCENE.GAME, this._gameParams);
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
// Shield card
if (this._hitTest(tx, ty, this._buttons.shield)) {
if (this._buffManager && !this._buffManager.hasBuff(BUFF_TYPE.SHIELD)) {
const result = this._buffManager.purchaseBuff(BUFF_TYPE.SHIELD);
if (!result.success) {
console.log(`[BuffSelectScene] Shield purchase failed: ${result.error}`);
}
}
return;
}
// Double Fire card
if (this._hitTest(tx, ty, this._buttons.doubleFire)) {
if (this._buffManager && !this._buffManager.hasBuff(BUFF_TYPE.DOUBLE_FIRE)) {
const result = this._buffManager.purchaseBuff(BUFF_TYPE.DOUBLE_FIRE);
if (!result.success) {
console.log(`[BuffSelectScene] Double Fire purchase failed: ${result.error}`);
}
}
return;
}
// Start button
if (this._hitTest(tx, ty, this._buttons.start)) {
this._startGame();
return;
}
// Skip button
if (this._hitTest(tx, ty, this._buttons.skip)) {
this._startGame();
return;
}
},
};
module.exports = BuffSelectScene;
+939
View File
@@ -0,0 +1,939 @@
/**
* GameScene.js
* Main battle scene - orchestrates map, tanks, bullets, power-ups, and HUD.
* This is the core gameplay scene that integrates all sub-systems.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
GAME_MODE,
MAP_OFFSET_X,
MAP_OFFSET_Y,
MAP_WIDTH,
MAP_HEIGHT,
TILE_SIZE,
GRID_COLS,
GRID_ROWS,
DIRECTION,
DIR_VECTORS,
BULLET_SPEED,
FIRE_LEVEL,
POWERUP_TYPE,
FREEZE_DURATION,
SHIELD_DURATION,
SHOVEL_DURATION,
TANK_TYPE,
TERRAIN,
} = require('../base/GameGlobal');
const ObjectPool = require('../base/ObjectPool');
const MapManager = require('../managers/MapManager');
const CollisionManager = require('../managers/CollisionManager');
const SpawnManager = require('../managers/SpawnManager');
const PlayerTank = require('../entities/PlayerTank');
const Bullet = require('../entities/Bullet');
const Explosion = require('../entities/Explosion');
const PowerUp = require('../entities/PowerUp');
const Joystick = require('../ui/Joystick');
const FireButton = require('../ui/FireButton');
const { getLevelData } = require('../data/LevelData');
const { t } = require('../i18n/I18n');
const GameScene = {
_mode: GAME_MODE.CLASSIC,
_level: 1,
_initialized: false,
_gameOver: false,
_victory: false,
_paused: false,
// Sub-systems
_mapManager: null,
_collisionManager: null,
_spawnManager: null,
_playerTank: null,
_joystick: null,
_fireButton: null,
// Entity lists
_enemies: [],
_bullets: [],
_explosions: [],
_powerUps: [],
// Object pools
_bulletPool: null,
_explosionPool: null,
// Game stats
_stats: null,
_levelStartTime: 0,
_freezeTimer: 0,
// Game over delay
_gameOverDelay: 0,
_gameOverDelayDuration: 2, // seconds
// Revive ad state
_reviveAdUsed: false,
_showingReviveDialog: false,
_reviveDialogButtons: null,
// Buff manager reference
_buffManager: null,
enter(params) {
this._mode = (params && params.mode) || GAME_MODE.CLASSIC;
this._level = (params && params.level) || 1;
this._gameOver = false;
this._victory = false;
this._paused = false;
this._freezeTimer = 0;
this._gameOverDelay = 0;
this._cachedBasePos = null;
this._reviveAdUsed = false;
this._showingReviveDialog = false;
this._reviveDialogButtons = null;
// Initialize buff manager reference
this._buffManager = GameGlobal.buffManager || null;
// Initialize stats
this._stats = {
kills: { normal: 0, fast: 0, armor: 0, boss: 0 },
totalKills: 0,
score: 0,
timeElapsed: 0,
baseAlive: true,
};
// Initialize object pools
this._bulletPool = new ObjectPool(() => new Bullet(), null, 20);
this._explosionPool = new ObjectPool(() => new Explosion(), null, 10);
// Initialize entity lists
this._enemies = [];
this._bullets = [];
this._explosions = [];
this._powerUps = [];
// Initialize map
this._mapManager = new MapManager();
const levelData = getLevelData(this._level);
this._mapManager.loadGrid(levelData.grid);
// Initialize spawn manager
this._spawnManager = new SpawnManager();
this._spawnManager.init(levelData);
// Initialize player
this._playerTank = new PlayerTank({
col: levelData.playerSpawn.col,
row: levelData.playerSpawn.row,
});
this._playerTank.activateShield(3000); // spawn protection
// Safety: ensure player spawn area is clear of blocking terrain
this._clearSpawnArea(levelData.playerSpawn.col, levelData.playerSpawn.row);
// Initialize collision manager
this._collisionManager = new CollisionManager({
mapManager: this._mapManager,
onExplosion: (x, y, isBig) => this._spawnExplosion(x, y, isBig),
eventBus: GameGlobal.eventBus,
});
// Initialize controls
this._joystick = new Joystick();
this._fireButton = new FireButton();
this._fireButton.onFire(() => this._playerFire());
// Event listeners
this._setupEvents();
this._levelStartTime = Date.now();
this._initialized = true;
// Activate pre-game buffs if any were purchased
if (this._buffManager) {
this._buffManager.activateBuffs(this._playerTank);
}
// Preload rewarded video ad for revive/double reward scenes
if (GameGlobal.adManager) {
GameGlobal.adManager.preloadRewardedVideo();
}
console.log(`[GameScene] Level ${this._level} started. Mode: ${this._mode}`);
},
exit() {
this._initialized = false;
this._cleanupEvents();
this._bullets = [];
this._enemies = [];
this._explosions = [];
this._powerUps = [];
// Clear buffs at end of round
if (this._buffManager) {
this._buffManager.clearBuffs();
}
},
_setupEvents() {
const eb = GameGlobal.eventBus;
this._onEnemyDestroyed = (data) => this._handleEnemyDestroyed(data);
this._onPlayerDestroyed = () => this._handlePlayerDestroyed();
this._onBaseDestroyed = () => this._handleBaseDestroyed();
eb.on('enemy:destroyed', this._onEnemyDestroyed);
eb.on('player:destroyed', this._onPlayerDestroyed);
eb.on('base:destroyed', this._onBaseDestroyed);
},
_cleanupEvents() {
const eb = GameGlobal.eventBus;
eb.off('enemy:destroyed', this._onEnemyDestroyed);
eb.off('player:destroyed', this._onPlayerDestroyed);
eb.off('base:destroyed', this._onBaseDestroyed);
},
// ============================================================
// Update
// ============================================================
update(dt) {
if (!this._initialized || this._paused) return;
// Game over delay (show explosion before transitioning)
if (this._gameOver || this._victory) {
this._gameOverDelay += dt;
// Still update explosions during delay
this._updateExplosions(dt);
if (this._gameOverDelay >= this._gameOverDelayDuration) {
this._transitionToResult();
}
return;
}
this._stats.timeElapsed += dt;
// Update map (shovel timer etc.)
this._mapManager.update(dt);
// Update freeze timer
if (this._freezeTimer > 0) {
this._freezeTimer -= dt * 1000;
if (this._freezeTimer < 0) this._freezeTimer = 0;
}
// Player movement
if (this._playerTank.alive && this._joystick.active && this._joystick.direction >= 0) {
this._playerTank.move(this._joystick.direction, dt, this._mapManager);
}
this._playerTank.update(dt);
// Update buff timers
if (this._buffManager) {
this._buffManager.update(dt, this._playerTank);
}
// Spawn enemies (pause spawning while freeze is active)
if (this._freezeTimer <= 0) {
const newEnemy = this._spawnManager.update(dt, this._enemies);
if (newEnemy) {
this._enemies.push(newEnemy);
}
}
// Update enemies — find base position from the grid
const basePos = this._findBasePos();
for (const enemy of this._enemies) {
if (this._freezeTimer > 0) {
enemy.frozen = true;
} else {
enemy.frozen = false;
}
enemy.update(dt, this._mapManager, basePos, (tank) => this._enemyFire(tank));
}
// Update bullets (freeze enemy bullets while freeze is active)
for (const bullet of this._bullets) {
if (this._freezeTimer > 0 && bullet.owner === 'enemy') {
continue; // enemy bullets are frozen
}
bullet.update(dt);
}
// Update power-ups
for (const pu of this._powerUps) {
pu.update(dt);
}
// Collision detection
this._collisionManager.update({
player: this._playerTank,
enemies: this._enemies,
bullets: this._bullets,
});
// Check power-up pickup
this._checkPowerUpPickup();
// Update explosions
this._updateExplosions(dt);
// Cleanup dead entities
this._cleanup();
// Check victory condition
this._checkVictory();
},
// ============================================================
// Render
// ============================================================
render(ctx) {
if (!this._initialized) return;
// Draw game area background
ctx.fillStyle = '#111111';
ctx.fillRect(MAP_OFFSET_X, MAP_OFFSET_Y, MAP_WIDTH, MAP_HEIGHT);
// Render map (terrain layer)
this._mapManager.render(ctx);
// Render power-ups
for (const pu of this._powerUps) {
pu.render(ctx);
}
// Render player
this._playerTank.render(ctx);
// Render enemies
for (const enemy of this._enemies) {
enemy.render(ctx);
}
// Render forest overlay (on top of tanks)
this._mapManager.renderForestOverlay(ctx);
// Render bullets
for (const bullet of this._bullets) {
bullet.render(ctx);
}
// Render explosions
for (const exp of this._explosions) {
exp.render(ctx);
}
// Render HUD
this._renderHUD(ctx);
// Render controls
this._joystick.render(ctx);
this._fireButton.render(ctx);
// Render pause overlay
if (this._paused && !this._showingReviveDialog) {
this._renderPauseOverlay(ctx);
}
// Render revive ad dialog
if (this._showingReviveDialog) {
this._renderReviveDialog(ctx);
}
// Game over text
if (this._gameOver) {
this._renderGameOverText(ctx, t('game.gameOver'));
} else if (this._victory) {
this._renderGameOverText(ctx, t('game.stageClear'));
}
},
// ============================================================
// HUD Rendering
// ============================================================
_renderHUD(ctx) {
// In landscape mode, HUD is rendered on the sides of the map
const leftX = MAP_OFFSET_X - 8; // right edge of left panel
const rightX = MAP_OFFSET_X + MAP_WIDTH + 8; // left edge of right panel
const topY = MAP_OFFSET_Y + 10;
// === Left side panel ===
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = 'bold 12px Arial';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
// Level info
ctx.fillText(t('game.level', { level: this._level }), leftX, topY);
// Player lives
ctx.fillStyle = '#FFD700';
ctx.font = '11px Arial';
ctx.fillText(t('game.hp', { count: this._playerTank.lives }), leftX, topY + 20);
// Fire level
ctx.fillText(t('game.fireLevel', { level: this._playerTank.fireLevel }), leftX, topY + 38);
// === Right side panel ===
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
// Remaining enemies
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = 'bold 12px Arial';
const aliveEnemies = this._enemies.filter((e) => e.alive).length;
const remaining = this._spawnManager.remainingToSpawn + aliveEnemies;
ctx.fillText(t('game.enemies', { count: remaining }), rightX, topY);
// Score
ctx.fillStyle = '#FFD700';
ctx.font = '11px Arial';
ctx.fillText(t('game.score', { score: this._stats.score }), rightX, topY + 20);
},
_renderPauseOverlay(ctx) {
ctx.fillStyle = 'rgba(0,0,0,0.6)';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('common.paused'), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 40);
ctx.font = '16px Arial';
ctx.fillText(t('common.tapContinue'), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 + 10);
},
_renderGameOverText(ctx, text) {
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
ctx.fillStyle = text === t('game.gameOver') ? '#FF0000' : '#00FF00';
ctx.font = 'bold 32px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(text, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2);
},
/**
* Render the revive dialog overlay with dual options (ad + gold).
* @private
*/
_renderReviveDialog(ctx) {
const cx = SCREEN_WIDTH / 2;
const cy = SCREEN_HEIGHT / 2;
// Dim background
ctx.fillStyle = 'rgba(0,0,0,0.7)';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Dialog box
const boxW = 300;
const boxH = 180;
ctx.fillStyle = 'rgba(30,30,30,0.95)';
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 2;
ctx.fillRect(cx - boxW / 2, cy - boxH / 2, boxW, boxH);
ctx.strokeRect(cx - boxW / 2, cy - boxH / 2, boxW, boxH);
// Title text
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 18px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('ad.reviveTitle') || 'Revive Chance', cx, cy - 55);
// Subtitle
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.fillText(t('ad.reviveDesc') || 'Choose how to revive and continue', cx, cy - 35);
const btns = this._reviveDialogButtons;
if (btns) {
// Watch Ad button (green) - only show if ad is available
if (btns.watchAd) {
ctx.fillStyle = '#4CAF50';
ctx.fillRect(btns.watchAd.x, btns.watchAd.y, btns.watchAd.w, btns.watchAd.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 13px Arial';
ctx.fillText(t('ad.watchAd') || '📺 Watch Ad (Free)', btns.watchAd.x + btns.watchAd.w / 2, btns.watchAd.y + btns.watchAd.h / 2);
}
// Gold Revive button (orange)
if (btns.goldRevive) {
const hasGold = GameGlobal.currencyManager && GameGlobal.currencyManager.hasGold(200);
ctx.fillStyle = hasGold ? '#FF9800' : '#555555';
ctx.fillRect(btns.goldRevive.x, btns.goldRevive.y, btns.goldRevive.w, btns.goldRevive.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 13px Arial';
ctx.fillText(t('ad.goldRevive') || '🪙 Gold Revive (200)', btns.goldRevive.x + btns.goldRevive.w / 2, btns.goldRevive.y + btns.goldRevive.h / 2);
}
// Give Up button (gray)
ctx.fillStyle = '#666666';
ctx.fillRect(btns.giveUp.x, btns.giveUp.y, btns.giveUp.w, btns.giveUp.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 13px Arial';
ctx.fillText(t('ad.giveUp') || 'Give Up', btns.giveUp.x + btns.giveUp.w / 2, btns.giveUp.y + btns.giveUp.h / 2);
}
},
// ============================================================
// Game Logic
// ============================================================
_playerFire() {
if (!this._playerTank.alive || !this._playerTank.canFire()) return;
if (this._gameOver || this._victory || this._paused) return;
const tank = this._playerTank;
const vec = DIR_VECTORS[tank.direction];
const bullet = this._bulletPool.get();
bullet.init({
x: tank.x + vec.dx * tank.halfSize,
y: tank.y + vec.dy * tank.halfSize,
direction: tank.direction,
owner: 'player',
canBreakSteel: tank.canBreakSteel(),
ownerTank: tank,
});
tank.activeBullets++;
this._bullets.push(bullet);
GameGlobal.audioManager.playSFX('shoot');
},
_enemyFire(enemyTank) {
if (!enemyTank.alive || !enemyTank.canFire()) return;
const vec = DIR_VECTORS[enemyTank.direction];
const bullet = this._bulletPool.get();
bullet.init({
x: enemyTank.x + vec.dx * enemyTank.halfSize,
y: enemyTank.y + vec.dy * enemyTank.halfSize,
direction: enemyTank.direction,
owner: 'enemy',
canBreakSteel: false,
ownerTank: enemyTank,
});
enemyTank.activeBullets++;
this._bullets.push(bullet);
GameGlobal.audioManager.playSFX('shoot');
},
_spawnExplosion(x, y, isBig) {
const exp = this._explosionPool.get();
exp.init(x, y, isBig);
this._explosions.push(exp);
GameGlobal.audioManager.playSFX(isBig ? 'explosion_big' : 'explosion_small');
},
/**
* Clear any blocking terrain at the player spawn area.
* Ensures the tank won't be stuck inside walls on spawn.
* @private
*/
_clearSpawnArea(col, row) {
const terrain = this._mapManager.getTerrain(row, col);
if (terrain !== TERRAIN.EMPTY && terrain !== TERRAIN.FOREST) {
this._mapManager.setTerrain(row, col, TERRAIN.EMPTY);
}
},
/**
* Find the base (eagle) position from the map grid.
* Scans for TERRAIN.BASE and returns its pixel center.
* Result is cached after first call per level.
* @private
* @returns {{x: number, y: number}}
*/
_findBasePos() {
if (this._cachedBasePos) return this._cachedBasePos;
// Scan grid for BASE terrain
for (let r = GRID_ROWS - 1; r >= 0; r--) {
for (let c = 0; c < GRID_COLS; c++) {
if (this._mapManager.getTerrain(r, c) === TERRAIN.BASE) {
this._cachedBasePos = {
x: MAP_OFFSET_X + c * TILE_SIZE + TILE_SIZE / 2,
y: MAP_OFFSET_Y + r * TILE_SIZE + TILE_SIZE / 2,
};
return this._cachedBasePos;
}
}
}
// Fallback: center-bottom
this._cachedBasePos = {
x: MAP_OFFSET_X + Math.floor(GRID_COLS / 2) * TILE_SIZE + TILE_SIZE / 2,
y: MAP_OFFSET_Y + (GRID_ROWS - 1) * TILE_SIZE + TILE_SIZE / 2,
};
return this._cachedBasePos;
},
_updateExplosions(dt) {
for (const exp of this._explosions) {
exp.update(dt);
}
},
_checkPowerUpPickup() {
if (!this._playerTank.alive) return;
const pb = this._playerTank.getBounds();
for (const pu of this._powerUps) {
if (!pu.alive) continue;
const pub = pu.getBounds();
if (
pb.x < pub.x + pub.w &&
pb.x + pb.w > pub.x &&
pb.y < pub.y + pub.h &&
pb.y + pb.h > pub.y
) {
this._applyPowerUp(pu);
pu.alive = false;
GameGlobal.audioManager.playSFX('powerup');
}
}
},
_applyPowerUp(powerUp) {
switch (powerUp.type) {
case POWERUP_TYPE.STAR:
this._playerTank.upgradeFireLevel();
break;
case POWERUP_TYPE.CLOCK:
this._freezeTimer = FREEZE_DURATION;
break;
case POWERUP_TYPE.BOMB:
// Destroy all on-screen enemies
for (const enemy of this._enemies) {
if (enemy.alive) {
enemy.alive = false;
this._spawnExplosion(enemy.x, enemy.y, true);
this._recordKill(enemy);
}
}
break;
case POWERUP_TYPE.HELMET:
this._playerTank.activateShield(SHIELD_DURATION);
break;
case POWERUP_TYPE.SHOVEL:
this._mapManager.activateShovel(SHOVEL_DURATION);
break;
case POWERUP_TYPE.TANK:
this._playerTank.addLife();
break;
}
},
_handleEnemyDestroyed(data) {
const enemy = data.enemy;
this._recordKill(enemy);
// Spawn power-up if this enemy was marked
if (enemy.hasPowerUp) {
const type = PowerUp.randomType(this._level);
const pos = PowerUp.randomPosition(this._mapManager);
const pu = new PowerUp(type, pos.x, pos.y);
this._powerUps.push(pu);
}
},
_recordKill(enemy) {
this._stats.totalKills++;
this._stats.score += enemy.score || 100;
switch (enemy.type) {
case TANK_TYPE.ENEMY_NORMAL:
this._stats.kills.normal++;
break;
case TANK_TYPE.ENEMY_FAST:
this._stats.kills.fast++;
break;
case TANK_TYPE.ENEMY_ARMOR:
this._stats.kills.armor++;
break;
case TANK_TYPE.ENEMY_BOSS:
this._stats.kills.boss++;
break;
}
},
_handlePlayerDestroyed() {
// Check if buff shield can absorb the hit
if (this._buffManager && this._buffManager.consumeShield(this._playerTank)) {
// Shield absorbed the damage, player survives
this._playerTank.hp = 1;
this._playerTank.alive = true;
return;
}
const hasLives = this._playerTank.die();
if (!hasLives) {
// Check if revive ad is available and not yet used this level
if (!this._reviveAdUsed) {
// Always show revive dialog (with ad and/or gold options)
this._showReviveAdDialog();
} else {
this._triggerGameOver();
}
}
},
/**
* Check if revive ad can be shown.
* @returns {boolean}
* @private
*/
_canShowReviveAd() {
if (!GameGlobal.adManager) return false;
const AdManager = require('../managers/AdManager');
return GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.REVIVE);
},
/**
* Show the revive dialog overlay with dual options.
* Pauses the game and presents watch-ad / gold-revive / give-up options.
* @private
*/
_showReviveAdDialog() {
this._showingReviveDialog = true;
this._paused = true;
const cx = SCREEN_WIDTH / 2;
const cy = SCREEN_HEIGHT / 2;
const btnW = 220;
const btnH = 36;
// Check if ad is available
const canShowAd = this._canShowReviveAd();
const buttons = {
giveUp: { x: cx - btnW / 2, y: cy + 50, w: btnW, h: btnH },
};
if (canShowAd) {
buttons.watchAd = { x: cx - btnW / 2, y: cy - 20, w: btnW, h: btnH };
buttons.goldRevive = { x: cx - btnW / 2, y: cy + 15, w: btnW, h: btnH };
buttons.giveUp = { x: cx - btnW / 2, y: cy + 50, w: btnW, h: btnH };
} else {
// No ad available, only show gold revive and give up
buttons.goldRevive = { x: cx - btnW / 2, y: cy - 5, w: btnW, h: btnH };
buttons.giveUp = { x: cx - btnW / 2, y: cy + 35, w: btnW, h: btnH };
}
this._reviveDialogButtons = buttons;
},
/**
* Handle the player choosing to revive with gold (200 gold).
* @private
*/
_onGoldRevive() {
const cm = GameGlobal.currencyManager;
if (cm && cm.spendGold(200)) {
this._showingReviveDialog = false;
this._reviveDialogButtons = null;
this._reviveAdUsed = true;
this._revivePlayer();
console.log('[GameScene] Player revived via gold (200)');
}
},
/**
* Handle the player choosing to watch the revive ad.
* @private
*/
_onReviveAdWatch() {
const AdManager = require('../managers/AdManager');
GameGlobal.adManager.showRewardedVideoForScene(
AdManager.AD_SCENE.REVIVE,
(completed) => {
this._showingReviveDialog = false;
this._reviveDialogButtons = null;
if (completed) {
this._reviveAdUsed = true;
this._revivePlayer();
} else {
this._triggerGameOver();
}
}
);
},
/**
* Handle the player choosing to give up (skip revive ad).
* @private
*/
_onReviveAdGiveUp() {
this._showingReviveDialog = false;
this._reviveDialogButtons = null;
this._triggerGameOver();
},
/**
* Revive the player: restore 1 life, keep fire level, respawn at start.
* @private
*/
_revivePlayer() {
this._paused = false;
this._playerTank.addLife();
// Respawn: reset position, alive=true, hp=1, shield protection
this._playerTank.respawn();
console.log('[GameScene] Player revived');
},
/**
* Trigger the game over state.
* @private
*/
_triggerGameOver() {
this._paused = false;
this._gameOver = true;
this._stats.baseAlive = !this._mapManager.baseDestroyed;
GameGlobal.audioManager.playSFX('gameover');
},
_handleBaseDestroyed() {
this._gameOver = true;
this._stats.baseAlive = false;
GameGlobal.audioManager.playSFX('gameover');
},
_checkVictory() {
if (this._gameOver || this._victory) return;
const allSpawned = this._spawnManager.allSpawned;
const allDead = this._enemies.every((e) => !e.alive);
if (allSpawned && allDead) {
this._victory = true;
this._stats.baseAlive = !this._mapManager.baseDestroyed;
GameGlobal.audioManager.playSFX('victory');
// Time bonus
const timeBonus = Math.max(0, 300 - Math.floor(this._stats.timeElapsed)) * 10;
this._stats.score += timeBonus;
// Base alive bonus
if (this._stats.baseAlive) {
this._stats.score += 1000;
}
}
},
_cleanup() {
// Recycle dead bullets
for (let i = this._bullets.length - 1; i >= 0; i--) {
if (!this._bullets[i].alive) {
this._bulletPool.put(this._bullets[i]);
this._bullets.splice(i, 1);
}
}
// Recycle dead explosions
for (let i = this._explosions.length - 1; i >= 0; i--) {
if (!this._explosions[i].alive) {
this._explosionPool.put(this._explosions[i]);
this._explosions.splice(i, 1);
}
}
// Remove dead power-ups
this._powerUps = this._powerUps.filter((pu) => pu.alive);
// Remove dead enemies (keep for counting)
// Don't remove - they're needed for allDead check
},
_transitionToResult() {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.RESULT)) {
const ResultScene = require('./ResultScene');
sm.register(SCENE.RESULT, ResultScene);
}
sm.switchTo(SCENE.RESULT, {
level: this._level,
mode: this._mode,
victory: this._victory,
stats: this._stats,
});
},
// ============================================================
// Touch Handling
// ============================================================
handleTouch(eventType, e) {
if (this._gameOver || this._victory) return;
// Handle revive dialog touches
if (this._showingReviveDialog && eventType === 'touchstart') {
const touches = e.touches;
for (let i = 0; i < touches.length; i++) {
const tx = touches[i].clientX;
const ty = touches[i].clientY;
const btns = this._reviveDialogButtons;
if (btns) {
// Watch Ad button
if (btns.watchAd && tx >= btns.watchAd.x && tx <= btns.watchAd.x + btns.watchAd.w &&
ty >= btns.watchAd.y && ty <= btns.watchAd.y + btns.watchAd.h) {
this._onReviveAdWatch();
return;
}
// Gold Revive button
if (btns.goldRevive && tx >= btns.goldRevive.x && tx <= btns.goldRevive.x + btns.goldRevive.w &&
ty >= btns.goldRevive.y && ty <= btns.goldRevive.y + btns.goldRevive.h) {
this._onGoldRevive();
return;
}
// Give Up button
if (tx >= btns.giveUp.x && tx <= btns.giveUp.x + btns.giveUp.w &&
ty >= btns.giveUp.y && ty <= btns.giveUp.y + btns.giveUp.h) {
this._onReviveAdGiveUp();
return;
}
}
}
return;
}
// Handle pause toggle
if (this._paused) {
if (eventType === 'touchstart') {
this._paused = false;
}
return;
}
// Distribute touches to controls
const touches = eventType === 'touchend' ? e.changedTouches : e.touches;
for (let i = 0; i < touches.length; i++) {
const touch = touches[i];
// Try joystick first
if (this._joystick.handleTouch(eventType, touch)) continue;
// Then fire button
if (this._fireButton.handleTouch(eventType, touch)) continue;
// Pause button area (top-right corner)
if (eventType === 'touchstart') {
if (touch.clientX > SCREEN_WIDTH - 50 && touch.clientY < MAP_OFFSET_Y) {
this._paused = true;
}
}
}
},
};
module.exports = GameScene;
+322
View File
@@ -0,0 +1,322 @@
/**
* MenuScene.js
* Main menu scene - displays game title and mode selection buttons.
* Rendered entirely with Canvas API (no DOM).
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
GAME_MODE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================
// Button Layout
// ============================================================
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.55, 280);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
const BTN_GAP = Math.min(8, SCREEN_HEIGHT * 0.015);
const BTN_START_Y = SCREEN_HEIGHT * 0.38;
const BTN_X = (SCREEN_WIDTH - BTN_WIDTH) / 2;
// Half-width buttons for the utility row (shop, battle pass, ranking, settings)
const HALF_BTN_WIDTH = (BTN_WIDTH - BTN_GAP) / 2;
// Main game mode buttons (full width, vertical)
const MAIN_BUTTONS = [
{ labelKey: 'menu.classic', mode: GAME_MODE.CLASSIC, scene: SCENE.GAME },
{ labelKey: 'menu.endless', mode: GAME_MODE.ENDLESS, scene: SCENE.GAME },
{ labelKey: 'menu.pvp', mode: GAME_MODE.PVP, scene: SCENE.PVP_ROOM },
{ labelKey: 'menu.team3v3', mode: GAME_MODE.TEAM_3V3, scene: SCENE.TEAM_ROOM },
];
// Utility buttons: shop, daily gold, ranking, settings (2x2 grid)
const UTIL_BUTTONS = [
{ labelKey: 'menu.shop', mode: null, scene: SCENE.SHOP },
{ labelKey: 'dailyGold.btn', mode: null, scene: 'DAILY_GOLD' },
{ labelKey: 'menu.ranking', mode: null, scene: SCENE.RANKING },
{ labelKey: 'menu.settings', mode: null, scene: SCENE.SETTINGS },
];
// Pre-calculate button rects for main buttons
const mainBtnRects = MAIN_BUTTONS.map((btn, i) => ({
x: BTN_X,
y: BTN_START_Y + i * (BTN_HEIGHT + BTN_GAP),
w: BTN_WIDTH,
h: BTN_HEIGHT,
...btn,
}));
// Pre-calculate button rects for utility buttons (2x2 grid)
const utilStartY = BTN_START_Y + MAIN_BUTTONS.length * (BTN_HEIGHT + BTN_GAP) + BTN_GAP;
const utilBtnRects = UTIL_BUTTONS.map((btn, i) => {
const col = i % 2;
const row = Math.floor(i / 2);
return {
x: BTN_X + col * (HALF_BTN_WIDTH + BTN_GAP),
y: utilStartY + row * (BTN_HEIGHT + BTN_GAP),
w: HALF_BTN_WIDTH,
h: BTN_HEIGHT,
...btn,
};
});
// Combined list for unified iteration
const buttonRects = [...mainBtnRects, ...utilBtnRects];
// ============================================================
// Menu Scene
// ============================================================
const MenuScene = {
_pressedIndex: -1,
_tankAnim: 0, // simple animation timer
enter() {
this._pressedIndex = -1;
this._tankAnim = 0;
// Auto-navigate to team room if there's a pending invite teamId
if (GameGlobal._pendingTeamId) {
const teamId = GameGlobal._pendingTeamId;
GameGlobal._pendingTeamId = null;
console.log(`[MenuScene] Found pendingTeamId: ${teamId}, will auto-navigate to TeamRoomScene`);
// Use setTimeout to allow the scene to fully initialize first
setTimeout(() => {
console.log(`[MenuScene] Auto-navigating to TeamRoomScene with teamId: ${teamId}`);
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_ROOM)) {
const TeamRoomScene = require('./TeamRoomScene');
sm.register(SCENE.TEAM_ROOM, TeamRoomScene);
}
sm.switchTo(SCENE.TEAM_ROOM, { teamId });
}, 100);
}
},
exit() {},
update(dt) {
this._tankAnim += dt;
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Decorative top bar
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
gradient.addColorStop(0, '#0f3460');
gradient.addColorStop(0.5, '#e94560');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
// Gold balance display at top
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'right';
ctx.textBaseline = 'top';
ctx.fillText(`🪙 ${gold}`, SCREEN_WIDTH - 15, 12);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 36px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('menu.title'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.15);
// Subtitle
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('menu.subtitle'), SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.22);
// Animated tank icon (simple oscillating triangle)
this._drawTankIcon(ctx, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.30);
// Main game mode buttons (full width)
for (let i = 0; i < mainBtnRects.length; i++) {
const btn = mainBtnRects[i];
const isPressed = this._pressedIndex === i;
ctx.fillStyle = isPressed ? '#0f3460' : COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, 8);
ctx.fill();
ctx.stroke();
ctx.fillStyle = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 18px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t(btn.labelKey), btn.x + btn.w / 2, btn.y + btn.h / 2);
}
// Utility buttons (2x2 grid, smaller font)
for (let i = 0; i < utilBtnRects.length; i++) {
const btn = utilBtnRects[i];
const globalIdx = mainBtnRects.length + i;
const isPressed = this._pressedIndex === globalIdx;
// Special rendering for daily gold button
const isDailyGold = btn.scene === 'DAILY_GOLD';
let label = t(btn.labelKey);
let btnColor = COLORS.MENU_BTN;
if (isDailyGold) {
const remaining = GameGlobal.adManager ? GameGlobal.adManager.getDailyGoldRemaining() : 0;
if (remaining > 0) {
label = `${t('dailyGold.btn')} ${remaining}/3`;
btnColor = '#2E7D32'; // green tint
} else {
label = t('dailyGold.exhausted') || 'Come back tomorrow';
btnColor = '#555555';
}
}
ctx.fillStyle = isPressed ? '#0f3460' : btnColor;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 1.5;
this._roundRect(ctx, btn.x, btn.y, btn.w, btn.h, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = isPressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, btn.x + btn.w / 2, btn.y + btn.h / 2);
}
// Footer
ctx.fillStyle = '#555555';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
ctx.fillText('v1.0.0', SCREEN_WIDTH / 2, SCREEN_HEIGHT - 20);
},
/**
* Draw a simple animated tank icon.
*/
_drawTankIcon(ctx, cx, cy) {
const bounce = Math.sin(this._tankAnim * 3) * 3;
const size = 20;
ctx.save();
ctx.translate(cx, cy + bounce);
// Tank body
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.fillRect(-size, -size / 2, size * 2, size);
// Tank turret
ctx.fillRect(-3, -size / 2 - 14, 6, 14);
// Tank tracks
ctx.fillStyle = '#B8860B';
ctx.fillRect(-size - 4, -size / 2, 4, size);
ctx.fillRect(size, -size / 2, 4, size);
ctx.restore();
},
/**
* Draw a rounded rectangle path.
*/
_roundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
},
handleTouch(eventType, e) {
if (eventType === 'touchstart') {
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
for (let i = 0; i < buttonRects.length; i++) {
const btn = buttonRects[i];
if (tx >= btn.x && tx <= btn.x + btn.w && ty >= btn.y && ty <= btn.y + btn.h) {
this._pressedIndex = i;
break;
}
}
} else if (eventType === 'touchend') {
if (this._pressedIndex >= 0) {
const btn = buttonRects[this._pressedIndex];
this._pressedIndex = -1;
// Navigate to the target scene
const sm = GameGlobal.sceneManager;
if (btn.scene === SCENE.GAME) {
// Route through BuffSelectScene for PvE modes
if (!sm._scenes.has(SCENE.BUFF_SELECT)) {
const BuffSelectScene = require('./BuffSelectScene');
sm.register(SCENE.BUFF_SELECT, BuffSelectScene);
}
sm.switchTo(SCENE.BUFF_SELECT, { mode: btn.mode });
} else if (btn.scene === 'DAILY_GOLD') {
// Handle daily gold ad
const adm = GameGlobal.adManager;
if (adm && adm.getDailyGoldRemaining() > 0) {
adm.showDailyGoldAd((completed) => {
if (completed) {
try {
wx.showToast({ title: t('dailyGold.reward') || '+100 Gold!', icon: 'none', duration: 1500 });
} catch (e) {}
}
});
}
} else if (btn.scene === SCENE.SHOP) {
if (!sm._scenes.has(SCENE.SHOP)) {
const ShopScene = require('./ShopScene');
sm.register(SCENE.SHOP, ShopScene);
}
sm.switchTo(SCENE.SHOP);
} else if (btn.scene === SCENE.SETTINGS) {
if (!sm._scenes.has(SCENE.SETTINGS)) {
const SettingsScene = require('./SettingsScene');
sm.register(SCENE.SETTINGS, SettingsScene);
}
sm.switchTo(SCENE.SETTINGS);
} else if (btn.scene === SCENE.RANKING) {
if (!sm._scenes.has(SCENE.RANKING)) {
const RankingScene = require('./RankingScene');
sm.register(SCENE.RANKING, RankingScene);
}
sm.switchTo(SCENE.RANKING);
} else if (btn.scene === SCENE.PVP_ROOM) {
if (!sm._scenes.has(SCENE.PVP_ROOM)) {
const RoomScene = require('./RoomScene');
sm.register(SCENE.PVP_ROOM, RoomScene);
}
sm.switchTo(SCENE.PVP_ROOM);
} else if (btn.scene === SCENE.TEAM_ROOM) {
if (!sm._scenes.has(SCENE.TEAM_ROOM)) {
const TeamRoomScene = require('./TeamRoomScene');
sm.register(SCENE.TEAM_ROOM, TeamRoomScene);
}
sm.switchTo(SCENE.TEAM_ROOM);
}
}
}
},
};
module.exports = MenuScene;
+146
View File
@@ -0,0 +1,146 @@
/**
* RankingScene.js
* Ranking/leaderboard scene.
* In production, this would use WeChat Open Data Domain (SharedCanvas).
* For now, displays local high scores with a placeholder for friend rankings.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
GAME_MODE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const StorageManager = require('../managers/StorageManager');
const RankingScene = {
_storage: null,
_scores: [],
_buttons: {},
enter() {
this._storage = new StorageManager();
this._buttons = {};
// Load local scores
this._scores = [
{
label: t('ranking.classicHigh'),
score: this._storage.getHighScore(GAME_MODE.CLASSIC),
},
{
label: t('ranking.endlessHigh'),
score: this._storage.getHighScore(GAME_MODE.ENDLESS),
},
{
label: t('ranking.highestLevel'),
score: this._storage.getHighestLevel(),
suffix: t('ranking.levelSuffix'),
},
];
},
exit() {},
update(dt) {},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const cx = SCREEN_WIDTH / 2;
let y = 60;
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('ranking.title'), cx, y);
y += 60;
// Local scores section
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('ranking.personalRecord'), cx, y);
y += 40;
for (const item of this._scores) {
// Card background
const cardW = SCREEN_WIDTH * 0.75;
const cardH = 55;
const cardX = cx - cardW / 2;
ctx.fillStyle = '#1e1e3a';
ctx.fillRect(cardX, y - cardH / 2, cardW, cardH);
ctx.strokeStyle = '#333366';
ctx.lineWidth = 1;
ctx.strokeRect(cardX, y - cardH / 2, cardW, cardH);
// Label
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = '14px Arial';
ctx.textAlign = 'left';
ctx.fillText(item.label, cardX + 15, y - 5);
// Score
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 18px Arial';
ctx.textAlign = 'right';
const suffix = item.suffix || t('ranking.scoreSuffix');
ctx.fillText(`${item.score} ${suffix}`, cardX + cardW - 15, y + 2);
y += 70;
}
y += 20;
// Friend ranking placeholder
ctx.fillStyle = '#666666';
ctx.font = '13px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('ranking.friendHint'), cx, y);
ctx.fillText('(SharedCanvas)', cx, y + 20);
// Back button
y = SCREEN_HEIGHT - 80;
const btnW = SCREEN_WIDTH * 0.4;
const btnH = 42;
const btnX = cx - btnW / 2;
this._buttons['back'] = { x: btnX, y: y - btnH / 2, w: btnW, h: btnH };
ctx.fillStyle = COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
ctx.fillRect(btnX, y - btnH / 2, btnW, btnH);
ctx.strokeRect(btnX, y - btnH / 2, btnW, btnH);
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('common.back'), cx, y);
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
const back = this._buttons['back'];
if (back && tx >= back.x && tx <= back.x + back.w && ty >= back.y && ty <= back.y + back.h) {
GameGlobal.sceneManager.switchTo(SCENE.MENU);
}
},
};
module.exports = RankingScene;
+420
View File
@@ -0,0 +1,420 @@
/**
* ResultScene.js
* Post-game result/settlement screen showing stats, score, and navigation options.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
GAME_MODE,
TANK_TYPE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const ResultScene = {
_level: 1,
_mode: GAME_MODE.CLASSIC,
_victory: false,
_stats: null,
_animTimer: 0,
_showButtons: false,
_isNewHighScore: false,
_adWatched: false,
enter(params) {
this._level = params.level || 1;
this._mode = params.mode || GAME_MODE.CLASSIC;
this._victory = params.victory || false;
this._stats = params.stats || {
kills: { normal: 0, fast: 0, armor: 0, boss: 0 },
totalKills: 0,
score: 0,
timeElapsed: 0,
baseAlive: true,
};
this._animTimer = 0;
this._showButtons = false;
this._isNewHighScore = false;
this._adWatched = false;
this._buttons = {};
// Save score and progress
this._saveResults();
// Interstitial ad is shown when player exits (next/retry/menu), not on enter
console.log(`[ResultScene] ${this._victory ? 'Victory' : 'Defeat'} - Score: ${this._stats.score}`);
},
_saveResults() {
const sm = GameGlobal.storageManager;
if (!sm) return;
// Update high score
this._isNewHighScore = sm.updateHighScore(this._mode, this._stats.score);
// Update highest level
if (this._victory) {
sm.updateHighestLevel(this._level);
}
// Update open data for friend ranking
if (GameGlobal.shareManager) {
GameGlobal.shareManager.updateOpenData(this._stats.score, this._level);
}
// Calculate and award gold coins
this._goldReward = this._calculateGoldReward();
if (this._goldReward > 0 && GameGlobal.currencyManager) {
GameGlobal.currencyManager.addGold(this._goldReward);
}
},
/**
* Calculate gold reward based on game performance.
* @returns {number}
* @private
*/
_calculateGoldReward() {
const stats = this._stats;
let gold = 50; // Base reward per requirements
// Bonus per kill type
gold += (stats.kills.normal || 0) * 5;
gold += (stats.kills.fast || 0) * 10;
gold += (stats.kills.armor || 0) * 15;
gold += (stats.kills.boss || 0) * 25;
// Victory bonus
if (this._victory) {
gold += 50;
}
// Time bonus (faster = more gold, max 30 gold for under 60s)
if (this._victory && stats.timeElapsed < 300) {
gold += Math.max(0, Math.floor((300 - stats.timeElapsed) / 10));
}
// Base alive bonus
if (stats.baseAlive) {
gold += 20;
}
return gold;
},
exit() {},
update(dt) {
this._animTimer += dt;
if (this._animTimer > 1.5 && !this._showButtons) {
this._showButtons = true;
}
},
render(ctx) {
// Background
ctx.fillStyle = '#0a0a1a';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const cx = SCREEN_WIDTH / 2;
let y = 35;
// Title
ctx.fillStyle = this._victory ? '#00FF00' : '#FF4444';
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
this._victory ? t('result.victory') : t('result.defeat'),
cx,
y
);
y += 30;
// Level info
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('result.level', { level: this._level }), cx, y);
y += 35;
// Kill statistics - horizontal table layout
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = 'bold 14px Arial';
ctx.fillText(t('result.killStats'), cx, y);
y += 20;
const killData = [
{ label: t('result.tankNormal'), count: this._stats.kills.normal, score: 100, color: '#AAAAAA' },
{ label: t('result.tankFast'), count: this._stats.kills.fast, score: 200, color: '#FF6347' },
{ label: t('result.tankArmor'), count: this._stats.kills.armor, score: 300, color: '#228B22' },
{ label: t('result.tankBoss'), count: this._stats.kills.boss, score: 500, color: '#8B0000' },
{ label: t('result.totalLabel'), count: this._stats.totalKills, score: null, total: this._stats.score, color: '#FFD700' },
];
// Table layout: first column for row labels, then 5 data columns
const colCount = killData.length;
const rowLabelWidth = 30; // Width for row label column
const tableWidth = SCREEN_WIDTH * 0.55;
const tableLeft = (SCREEN_WIDTH - tableWidth) / 2;
const dataColWidth = (tableWidth - rowLabelWidth) / colCount;
const dataLeft = tableLeft + rowLabelWidth;
// Row 1: Column headers (blank first col + tank type names + total)
ctx.font = 'bold 11px Arial';
ctx.textBaseline = 'middle';
for (let i = 0; i < colCount; i++) {
const showDelay = i * 0.3;
if (this._animTimer < showDelay) continue;
const colCx = dataLeft + dataColWidth * i + dataColWidth / 2;
ctx.textAlign = 'center';
ctx.fillStyle = killData[i].color;
ctx.fillText(killData[i].label, colCx, y);
}
y += 16;
// Row 2: Kill counts (first col = "击杀")
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.fillStyle = '#AAAAAA';
ctx.fillText(t('result.rowKills'), tableLeft, y);
for (let i = 0; i < colCount; i++) {
const showDelay = i * 0.3;
if (this._animTimer < showDelay) continue;
const colCx = dataLeft + dataColWidth * i + dataColWidth / 2;
ctx.textAlign = 'center';
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.fillText(`×${killData[i].count}`, colCx, y);
}
y += 16;
// Row 3: Scores (first col = "得分")
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.fillStyle = '#AAAAAA';
ctx.fillText(t('result.rowScore'), tableLeft, y);
for (let i = 0; i < colCount; i++) {
const showDelay = i * 0.3;
if (this._animTimer < showDelay) continue;
const colCx = dataLeft + dataColWidth * i + dataColWidth / 2;
ctx.textAlign = 'center';
ctx.fillStyle = '#FFD700';
const scoreVal = killData[i].total != null ? killData[i].total : killData[i].count * killData[i].score;
ctx.fillText(`${scoreVal}`, colCx, y);
}
y += 10;
// Divider
ctx.strokeStyle = '#333333';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(cx - 120, y);
ctx.lineTo(cx + 120, y);
ctx.stroke();
y += 18;
// Time
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
const minutes = Math.floor(this._stats.timeElapsed / 60);
const seconds = Math.floor(this._stats.timeElapsed % 60);
ctx.fillText(
t('result.time', { minutes, seconds: seconds.toString().padStart(2, '0') }),
cx,
y
);
y += 18;
// Base status
ctx.fillText(
this._stats.baseAlive ? t('result.baseAlive') : t('result.baseDestroyed'),
cx,
y
);
// New high score indicator
if (this._isNewHighScore) {
y += 20;
ctx.fillStyle = '#FF69B4';
ctx.font = 'bold 13px Arial';
ctx.fillText(t('result.newRecord'), cx, y);
}
// Gold reward display
if (this._goldReward > 0) {
y += 22;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 14px Arial';
const goldLabel = this._adWatched
? `🪙 +${this._goldReward} (${t('result.doubled') || '2x!'})`
: `🪙 +${this._goldReward}`;
ctx.fillText(goldLabel, cx, y);
}
// Buttons (shown after animation)
if (this._showButtons) {
// Calculate how many buttons will be shown
let btnCount = 2; // retry + menu always present
if (this._victory) btnCount += 2; // share + next
if (!this._adWatched) btnCount += 1; // ad_double
const btnSpacing = 38;
const totalBtnHeight = btnCount * btnSpacing;
// Start buttons so they end 15px above screen bottom
y = SCREEN_HEIGHT - totalBtnHeight - 15;
// Share challenge button
if (this._victory) {
this._drawButton(ctx, cx, y, t('result.share'), 'share');
y += btnSpacing;
}
// Double score ad button (if not watched)
if (!this._adWatched) {
this._drawButton(ctx, cx, y, t('result.adDouble'), 'ad_double');
y += btnSpacing;
}
if (this._victory) {
this._drawButton(ctx, cx, y, t('result.nextLevel'), 'next');
y += btnSpacing;
}
// Retry button
this._drawButton(ctx, cx, y, t('result.retry'), 'retry');
y += btnSpacing;
// Menu button
this._drawButton(ctx, cx, y, t('result.backMenu'), 'menu');
}
},
_drawButton(ctx, cx, y, label, id) {
const w = SCREEN_WIDTH * 0.55;
const h = 36;
const x = cx - w / 2;
// Store button rect for touch detection
if (!this._buttons) this._buttons = {};
this._buttons[id] = { x, y: y - h / 2, w, h };
ctx.fillStyle = COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
// Rounded rect
ctx.beginPath();
const r = 6;
ctx.moveTo(x + r, y - h / 2);
ctx.lineTo(x + w - r, y - h / 2);
ctx.arcTo(x + w, y - h / 2, x + w, y - h / 2 + r, r);
ctx.lineTo(x + w, y + h / 2 - r);
ctx.arcTo(x + w, y + h / 2, x + w - r, y + h / 2, r);
ctx.lineTo(x + r, y + h / 2);
ctx.arcTo(x, y + h / 2, x, y + h / 2 - r, r);
ctx.lineTo(x, y - h / 2 + r);
ctx.arcTo(x, y - h / 2, x + r, y - h / 2, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, cx, y);
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart' || !this._showButtons) return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
if (!this._buttons) return;
for (const [id, rect] of Object.entries(this._buttons)) {
if (tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h) {
this._handleButtonPress(id);
break;
}
}
},
_handleButtonPress(id) {
const sm = GameGlobal.sceneManager;
switch (id) {
case 'next':
sm.switchTo(SCENE.GAME, {
mode: this._mode,
level: this._level + 1,
});
break;
case 'retry':
sm.switchTo(SCENE.GAME, {
mode: this._mode,
level: this._level,
});
break;
case 'menu':
// Show interstitial ad when leaving result screen
if (GameGlobal.adManager) {
GameGlobal.adManager.showInterstitial();
}
sm.switchTo(SCENE.MENU);
break;
case 'share':
if (GameGlobal.shareManager) {
GameGlobal.shareManager.shareChallenge(this._level, this._stats.score);
}
break;
case 'ad_double': {
const AdManager = require('../managers/AdManager');
if (GameGlobal.adManager &&
GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.DOUBLE_REWARD)) {
GameGlobal.adManager.showRewardedVideoForScene(
AdManager.AD_SCENE.DOUBLE_REWARD,
(completed) => {
if (completed) {
this._stats.score *= 2;
this._adWatched = true;
// Re-save with doubled score
if (GameGlobal.storageManager) {
GameGlobal.storageManager.updateHighScore(this._mode, this._stats.score);
}
// Award bonus gold (double the original reward)
if (this._goldReward && GameGlobal.currencyManager) {
GameGlobal.currencyManager.addGold(this._goldReward);
this._goldReward *= 2; // Update display
}
}
}
);
} else {
console.warn('[ResultScene] Double reward ad not available');
}
break;
}
}
},
};
module.exports = ResultScene;
+539
View File
@@ -0,0 +1,539 @@
/**
* RoomScene.js
* Room creation/joining UI for PVP online multiplayer mode.
* Allows players to create a room or join an existing one by room code.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
NET_MSG,
SERVER_URL,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================
// Layout Constants
// ============================================================
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.5, 240);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.08);
const BTN_GAP = 14;
const CENTER_X = SCREEN_WIDTH / 2;
// ============================================================
// Room Scene States
// ============================================================
const ROOM_STATE = {
IDLE: 'idle', // Initial state: show create/join buttons
CREATING: 'creating', // Connecting and creating room
WAITING: 'waiting', // Room created, waiting for opponent
JOINING: 'joining', // Joining a room
INPUT_CODE: 'input', // Entering room code
COUNTDOWN: 'countdown', // Both players ready, counting down
ERROR: 'error', // Error state
};
// ============================================================
// Room Scene
// ============================================================
const RoomScene = {
_state: ROOM_STATE.IDLE,
_roomCode: '',
_inputCode: '',
_errorMsg: '',
_countdown: 3,
_countdownTimer: 0,
_animTimer: 0,
_networkManager: null,
_unsubscribers: [],
// Server URL (from global config)
_serverUrl: SERVER_URL,
// Button rects (calculated in enter)
_createBtnRect: null,
_joinBtnRect: null,
_backBtnRect: null,
_confirmBtnRect: null,
_numpadRects: [],
_deleteBtnRect: null,
enter() {
this._state = ROOM_STATE.IDLE;
this._roomCode = '';
this._inputCode = '';
this._errorMsg = '';
this._countdown = 3;
this._countdownTimer = 0;
this._animTimer = 0;
this._pendingStartData = null;
this._networkManager = GameGlobal.networkManager;
// Calculate button positions
const btnY = SCREEN_HEIGHT * 0.4;
this._createBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: btnY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._joinBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: btnY + BTN_HEIGHT + BTN_GAP,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._backBtnRect = {
x: 10,
y: 10,
w: 60,
h: 30,
};
// Numpad for room code input (3x4 grid: 1-9, 0, delete, confirm)
this._buildNumpad();
// Confirm button for code input
this._confirmBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: SCREEN_HEIGHT * 0.75,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
// Setup network event listeners
this._setupNetworkEvents();
},
exit() {
this._cleanupNetworkEvents();
},
_buildNumpad() {
const padWidth = Math.min(SCREEN_WIDTH * 0.6, 200);
const padHeight = Math.min(SCREEN_HEIGHT * 0.35, 180);
const startX = CENTER_X - padWidth / 2;
const startY = SCREEN_HEIGHT * 0.42;
const cellW = padWidth / 3;
const cellH = padHeight / 4;
this._numpadRects = [];
const nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, null, 0, 'del'];
for (let i = 0; i < 12; i++) {
const col = i % 3;
const row = Math.floor(i / 3);
if (nums[i] !== null) {
this._numpadRects.push({
x: startX + col * cellW,
y: startY + row * cellH,
w: cellW - 4,
h: cellH - 4,
value: nums[i],
});
}
}
},
_setupNetworkEvents() {
this._cleanupNetworkEvents();
const nm = this._networkManager;
if (!nm) return;
const unsubs = [];
unsubs.push(nm.on(NET_MSG.ROOM_CREATED, (data) => {
this._roomCode = data.roomId || data.roomCode || '';
this._state = ROOM_STATE.WAITING;
}));
unsubs.push(nm.on(NET_MSG.ROOM_JOINED, (data) => {
this._roomCode = data.roomId || data.roomCode || '';
this._state = ROOM_STATE.WAITING;
}));
unsubs.push(nm.on(NET_MSG.OPPONENT_JOINED, () => {
this._state = ROOM_STATE.COUNTDOWN;
this._countdown = 3;
this._countdownTimer = 0;
}));
unsubs.push(nm.on(NET_MSG.GAME_START, (data) => {
// Server authoritative game start — always use server data (contains mapId)
this._pendingStartData = data;
if (this._state !== ROOM_STATE.COUNTDOWN) {
// Guest path: not in countdown state, start game immediately
this._startGame(data);
} else if (this._countdown <= 0) {
// Host path: countdown already finished, start immediately
this._startGame(data);
}
// Host path: countdown still running, will pick up pendingStartData when done
}));
unsubs.push(nm.on(NET_MSG.ROOM_ERROR, (data) => {
this._errorMsg = data.message || 'Unknown error';
this._state = ROOM_STATE.ERROR;
}));
unsubs.push(nm.on('error', () => {
this._errorMsg = t('common.connectFailed');
this._state = ROOM_STATE.ERROR;
}));
unsubs.push(nm.on('disconnected', () => {
if (this._state !== ROOM_STATE.IDLE) {
this._errorMsg = t('common.disconnected');
this._state = ROOM_STATE.ERROR;
}
}));
this._unsubscribers = unsubs;
},
_cleanupNetworkEvents() {
for (const unsub of this._unsubscribers) {
if (typeof unsub === 'function') unsub();
}
this._unsubscribers = [];
},
update(dt) {
this._animTimer += dt;
if (this._state === ROOM_STATE.COUNTDOWN) {
this._countdownTimer += dt;
if (this._countdownTimer >= 1) {
this._countdownTimer -= 1;
this._countdown--;
if (this._countdown <= 0) {
// Countdown finished — only start if we already received server GAME_START
if (this._pendingStartData) {
this._startGame(this._pendingStartData);
}
// Otherwise wait for server GAME_START message
}
}
}
},
_startGame(data) {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
const TeamGameScene = require('./TeamGameScene');
sm.register(SCENE.TEAM_GAME, TeamGameScene);
}
const myPlayerId = this._networkManager ? this._networkManager.playerId : 'local';
const playerSlot = this._networkManager ? this._networkManager.playerSlot : 1;
// Build teamA/teamB from GAME_START data (sent by server for 1v1 via TeamRoom)
let teamA = data.teamA || [];
let teamB = data.teamB || [];
// Fallback: if server didn't send teamA/teamB (legacy), construct from playerSlot
if (teamA.length === 0 && teamB.length === 0) {
if (playerSlot === 1) {
teamA = [{ playerId: myPlayerId, isBot: false }];
teamB = [{ playerId: 'opponent', isBot: false }];
} else {
teamA = [{ playerId: 'opponent', isBot: false }];
teamB = [{ playerId: myPlayerId, isBot: false }];
}
}
sm.switchTo(SCENE.TEAM_GAME, {
teamId: this._roomCode,
roomId: data.roomId || this._roomCode,
mapId: data.mapId || null,
teamA,
teamB,
teamABaseHp: data.teamABaseHp,
teamBBaseHp: data.teamBBaseHp,
myPlayerId,
battleMode: data.battleMode || '1v1',
});
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Top bar
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
gradient.addColorStop(0, '#0f3460');
gradient.addColorStop(0.5, '#e94560');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
// Back button
this._drawButton(ctx, this._backBtnRect, t('common.back'), false, 12);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('room.title'), CENTER_X, SCREEN_HEIGHT * 0.12);
// Render based on state
switch (this._state) {
case ROOM_STATE.IDLE:
this._renderIdle(ctx);
break;
case ROOM_STATE.CREATING:
case ROOM_STATE.JOINING:
this._renderConnecting(ctx);
break;
case ROOM_STATE.WAITING:
this._renderWaiting(ctx);
break;
case ROOM_STATE.INPUT_CODE:
this._renderInputCode(ctx);
break;
case ROOM_STATE.COUNTDOWN:
this._renderCountdown(ctx);
break;
case ROOM_STATE.ERROR:
this._renderError(ctx);
break;
}
},
_renderIdle(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.idleHint'), CENTER_X, SCREEN_HEIGHT * 0.28);
this._drawButton(ctx, this._createBtnRect, t('room.create'));
this._drawButton(ctx, this._joinBtnRect, t('room.join'));
},
_renderConnecting(ctx) {
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.connecting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.5);
},
_renderWaiting(ctx) {
// Room code display
ctx.fillStyle = '#FFFFFF';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.roomCode'), CENTER_X, SCREEN_HEIGHT * 0.32);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 36px Arial';
ctx.fillText(this._roomCode, CENTER_X, SCREEN_HEIGHT * 0.42);
// Waiting animation
const dots = '.'.repeat(Math.floor(this._animTimer * 2) % 4);
ctx.fillStyle = '#AAAAAA';
ctx.font = '16px Arial';
ctx.fillText(t('room.waiting', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55);
// Hint
ctx.fillStyle = '#666666';
ctx.font = '12px Arial';
ctx.fillText(t('room.shareHint'), CENTER_X, SCREEN_HEIGHT * 0.65);
},
_renderInputCode(ctx) {
ctx.fillStyle = '#FFFFFF';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.inputCode'), CENTER_X, SCREEN_HEIGHT * 0.25);
// Code display box
const boxW = Math.min(SCREEN_WIDTH * 0.5, 180);
const boxH = 40;
const boxX = CENTER_X - boxW / 2;
const boxY = SCREEN_HEIGHT * 0.30;
ctx.fillStyle = '#1a1a2e';
ctx.strokeStyle = COLORS.MENU_TITLE;
ctx.lineWidth = 2;
ctx.fillRect(boxX, boxY, boxW, boxH);
ctx.strokeRect(boxX, boxY, boxW, boxH);
// Input text
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
const displayCode = this._inputCode + (Math.floor(this._animTimer * 2) % 2 === 0 ? '|' : '');
ctx.fillText(displayCode, CENTER_X, boxY + boxH / 2);
// Numpad
for (const btn of this._numpadRects) {
const label = btn.value === 'del' ? '⌫' : String(btn.value);
this._drawButton(ctx, btn, label, false, 16);
}
// Confirm button
if (this._inputCode.length >= 4) {
this._drawButton(ctx, this._confirmBtnRect, t('common.joinBtn'), false, 16);
}
},
_renderCountdown(ctx) {
ctx.fillStyle = '#00FF00';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('room.opponentFound'), CENTER_X, SCREEN_HEIGHT * 0.35);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 64px Arial';
ctx.fillText(String(this._countdown), CENTER_X, SCREEN_HEIGHT * 0.52);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('room.starting'), CENTER_X, SCREEN_HEIGHT * 0.65);
},
_renderError(ctx) {
ctx.fillStyle = '#FF4444';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMsg, CENTER_X, SCREEN_HEIGHT * 0.45);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('room.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
},
_drawButton(ctx, rect, label, pressed, fontSize) {
if (!rect) return;
const fs = fontSize || 16;
ctx.fillStyle = pressed ? '#0f3460' : COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
// Rounded rect
const r = 6;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = pressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = `bold ${fs}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
// Back button (always available)
if (this._hitTest(tx, ty, this._backBtnRect)) {
this._goBack();
return;
}
switch (this._state) {
case ROOM_STATE.IDLE:
if (this._hitTest(tx, ty, this._createBtnRect)) {
this._handleCreateRoom();
} else if (this._hitTest(tx, ty, this._joinBtnRect)) {
this._state = ROOM_STATE.INPUT_CODE;
this._inputCode = '';
}
break;
case ROOM_STATE.INPUT_CODE:
// Check numpad
for (const btn of this._numpadRects) {
if (this._hitTest(tx, ty, btn)) {
if (btn.value === 'del') {
this._inputCode = this._inputCode.slice(0, -1);
} else if (this._inputCode.length < 6) {
this._inputCode += String(btn.value);
}
return;
}
}
// Check confirm
if (this._inputCode.length >= 4 && this._hitTest(tx, ty, this._confirmBtnRect)) {
this._handleJoinRoom();
}
break;
case ROOM_STATE.ERROR:
this._state = ROOM_STATE.IDLE;
this._errorMsg = '';
break;
case ROOM_STATE.WAITING:
// Allow going back while waiting
break;
}
},
async _handleCreateRoom() {
this._state = ROOM_STATE.CREATING;
const nm = this._networkManager;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
}
nm.createRoom();
},
async _handleJoinRoom() {
this._state = ROOM_STATE.JOINING;
const nm = this._networkManager;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = ROOM_STATE.ERROR;
return;
}
}
nm.joinRoom(this._inputCode);
},
_goBack() {
// Disconnect if connected
if (this._networkManager && this._networkManager.connected) {
this._networkManager.disconnect();
}
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
},
};
module.exports = RoomScene;
+167
View File
@@ -0,0 +1,167 @@
/**
* SettingsScene.js
* Settings screen with sound, music, and vibration toggles.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const SettingsScene = {
_settings: {
soundEnabled: true,
musicEnabled: true,
vibrationEnabled: true,
},
_buttons: {},
enter() {
// Load settings from storage
try {
const saved = wx.getStorageSync('game_settings');
if (saved) {
this._settings = { ...this._settings, ...JSON.parse(saved) };
}
} catch (e) {
console.warn('[Settings] Failed to load settings:', e);
}
this._buttons = {};
},
exit() {
// Save settings
try {
wx.setStorageSync('game_settings', JSON.stringify(this._settings));
} catch (e) {
console.warn('[Settings] Failed to save settings:', e);
}
},
update(dt) {},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const cx = SCREEN_WIDTH / 2;
let y = 60;
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 28px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('settings.title'), cx, y);
y += 70;
// Toggle items
const toggles = [
{ key: 'soundEnabled', label: t('settings.sound'), icon: '🔊' },
{ key: 'musicEnabled', label: t('settings.music'), icon: '🎵' },
{ key: 'vibrationEnabled', label: t('settings.vibration'), icon: '📳' },
];
for (const toggle of toggles) {
this._renderToggle(ctx, cx, y, toggle);
y += 70;
}
// Back button
y = SCREEN_HEIGHT - 80;
this._renderBackButton(ctx, cx, y);
},
_renderToggle(ctx, cx, y, toggle) {
const w = SCREEN_WIDTH * 0.7;
const h = 50;
const x = cx - w / 2;
const isOn = this._settings[toggle.key];
// Store button rect
this._buttons[toggle.key] = { x, y: y - h / 2, w, h };
// Background
ctx.fillStyle = '#1e1e3a';
ctx.fillRect(x, y - h / 2, w, h);
ctx.strokeStyle = '#333366';
ctx.lineWidth = 1;
ctx.strokeRect(x, y - h / 2, w, h);
// Icon and label
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = '16px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`${toggle.icon} ${toggle.label}`, x + 15, y);
// Toggle switch
const switchW = 50;
const switchH = 26;
const switchX = x + w - switchW - 15;
const switchY = y - switchH / 2;
// Switch track
ctx.fillStyle = isOn ? '#4CAF50' : '#555555';
ctx.beginPath();
ctx.arc(switchX + switchH / 2, y, switchH / 2, Math.PI / 2, Math.PI * 3 / 2);
ctx.arc(switchX + switchW - switchH / 2, y, switchH / 2, -Math.PI / 2, Math.PI / 2);
ctx.closePath();
ctx.fill();
// Switch knob
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
const knobX = isOn ? switchX + switchW - switchH / 2 : switchX + switchH / 2;
ctx.arc(knobX, y, switchH / 2 - 3, 0, Math.PI * 2);
ctx.fill();
},
_renderBackButton(ctx, cx, y) {
const w = SCREEN_WIDTH * 0.4;
const h = 42;
const x = cx - w / 2;
this._buttons['back'] = { x, y: y - h / 2, w, h };
ctx.fillStyle = COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
ctx.fillRect(x, y - h / 2, w, h);
ctx.strokeRect(x, y - h / 2, w, h);
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('common.back'), cx, y);
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
for (const [key, rect] of Object.entries(this._buttons)) {
if (tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h) {
if (key === 'back') {
GameGlobal.sceneManager.switchTo(SCENE.MENU);
} else if (this._settings.hasOwnProperty(key)) {
this._settings[key] = !this._settings[key];
// Notify audio system
GameGlobal.eventBus.emit('settings:changed', this._settings);
}
break;
}
}
},
};
module.exports = SettingsScene;
+279
View File
@@ -0,0 +1,279 @@
/**
* ShopScene.js
* Simplified shop scene for monetization-lite.
* Shows 3 products: Ad-Free (¥18), Gold Pack (¥6), Newcomer Pack (¥1).
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// Layout
const CARD_W = Math.min(SCREEN_WIDTH * 0.85, 320);
const CARD_H = 70;
const CARD_GAP = 12;
const CARD_X = (SCREEN_WIDTH - CARD_W) / 2;
const CARDS_START_Y = SCREEN_HEIGHT * 0.25;
const ShopScene = {
_buttons: {},
_message: '',
_messageTimer: 0,
enter() {
this._buttons = {};
this._message = '';
this._messageTimer = 0;
this._calculateLayout();
},
exit() {},
_calculateLayout() {
let y = CARDS_START_Y;
// Ad-Free card
this._buttons.adFree = { x: CARD_X, y, w: CARD_W, h: CARD_H };
y += CARD_H + CARD_GAP;
// Gold Pack card
this._buttons.goldPack = { x: CARD_X, y, w: CARD_W, h: CARD_H };
y += CARD_H + CARD_GAP;
// Newcomer Pack card (only if available)
this._buttons.newcomerPack = { x: CARD_X, y, w: CARD_W, h: CARD_H };
y += CARD_H + CARD_GAP + 10;
// Back button
const backW = 100;
const backH = 36;
this._buttons.back = { x: (SCREEN_WIDTH - backW) / 2, y, w: backW, h: backH };
},
update(dt) {
if (this._messageTimer > 0) {
this._messageTimer -= dt;
if (this._messageTimer <= 0) {
this._message = '';
}
}
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('shop.title') || 'Shop', SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.08);
// Gold balance
const gold = GameGlobal.currencyManager ? GameGlobal.currencyManager.getGold() : 0;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 16px Arial';
ctx.fillText(`🪙 ${gold}`, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.16);
const pm = GameGlobal.paymentManager;
// Ad-Free card
const adFreePurchased = pm && pm.isAdFreePurchased();
this._renderProductCard(ctx, this._buttons.adFree,
t('shop.adFree') || 'Remove Ads',
t('shop.adFreeDesc') || 'Permanently remove interstitial ads',
adFreePurchased ? (t('shop.adFreeOwned') || 'Owned') : '¥18',
adFreePurchased
);
// Gold Pack card
this._renderProductCard(ctx, this._buttons.goldPack,
t('shop.goldPack') || 'Gold Pack',
t('shop.goldPackDesc') || '1000 Gold',
'¥6',
false
);
// Newcomer Pack card
const newcomerAvailable = pm && pm.isNewcomerPackAvailable();
if (newcomerAvailable) {
const remainingMs = pm.getNewcomerPackRemainingMs();
const hours = Math.floor(remainingMs / (60 * 60 * 1000));
const mins = Math.floor((remainingMs % (60 * 60 * 1000)) / (60 * 1000));
const timeStr = `${hours}h ${mins}m`;
this._renderProductCard(ctx, this._buttons.newcomerPack,
t('shop.newcomerPack') || 'Newcomer Pack',
`${t('shop.newcomerPackDesc') || '500 Gold'} (⏰ ${timeStr})`,
'¥1',
false,
'#FF9800' // highlight color for limited time
);
} else {
// Show expired/purchased state
this._renderProductCard(ctx, this._buttons.newcomerPack,
t('shop.newcomerPack') || 'Newcomer Pack',
t('shop.newcomerExpired') || 'Expired',
'--',
true
);
}
// Back button
this._renderButton(ctx, this._buttons.back, t('common.back') || '← Back', '#666666');
// Toast message
if (this._message) {
ctx.fillStyle = 'rgba(0,0,0,0.7)';
const msgW = 200;
const msgH = 36;
ctx.fillRect(SCREEN_WIDTH / 2 - msgW / 2, SCREEN_HEIGHT * 0.92 - msgH / 2, msgW, msgH);
ctx.fillStyle = '#FFFFFF';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._message, SCREEN_WIDTH / 2, SCREEN_HEIGHT * 0.92);
}
},
_renderProductCard(ctx, rect, title, desc, priceLabel, disabled, highlightColor) {
if (!rect) return;
// Card background
ctx.fillStyle = disabled ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.06)';
ctx.strokeStyle = highlightColor || (disabled ? '#333333' : '#555555');
ctx.lineWidth = highlightColor ? 2 : 1;
const r = 8;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Title (left aligned)
ctx.fillStyle = disabled ? '#666666' : '#FFFFFF';
ctx.font = 'bold 15px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(title, rect.x + 15, rect.y + rect.h * 0.35);
// Description
ctx.fillStyle = disabled ? '#444444' : '#AAAAAA';
ctx.font = '11px Arial';
ctx.fillText(desc, rect.x + 15, rect.y + rect.h * 0.65);
// Price (right aligned)
ctx.fillStyle = disabled ? '#444444' : (highlightColor || '#FFD700');
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'right';
ctx.fillText(priceLabel, rect.x + rect.w - 15, rect.y + rect.h / 2);
},
_renderButton(ctx, rect, label, color) {
if (!rect) return;
ctx.fillStyle = color;
const r = 6;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
_showMessage(msg) {
this._message = msg;
this._messageTimer = 2;
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
const pm = GameGlobal.paymentManager;
// Ad-Free
if (this._hitTest(tx, ty, this._buttons.adFree)) {
if (pm && !pm.isAdFreePurchased()) {
pm.purchaseAdFree((result) => {
if (result.success) {
this._showMessage('✅ Ad-Free activated!');
} else {
this._showMessage(result.error || 'Purchase failed');
}
});
}
return;
}
// Gold Pack
if (this._hitTest(tx, ty, this._buttons.goldPack)) {
if (pm) {
pm.purchaseGoldPack((result) => {
if (result.success) {
this._showMessage('✅ +1000 Gold!');
} else {
this._showMessage(result.error || 'Purchase failed');
}
});
}
return;
}
// Newcomer Pack
if (this._hitTest(tx, ty, this._buttons.newcomerPack)) {
if (pm && pm.isNewcomerPackAvailable()) {
pm.purchaseNewcomerPack((result) => {
if (result.success) {
this._showMessage('✅ +500 Gold!');
} else {
this._showMessage(result.error || 'Purchase failed');
}
});
}
return;
}
// Back
if (this._hitTest(tx, ty, this._buttons.back)) {
GameGlobal.sceneManager.switchTo(SCENE.MENU);
return;
}
},
};
module.exports = ShopScene;
File diff suppressed because it is too large Load Diff
+588
View File
@@ -0,0 +1,588 @@
/**
* TeamResultScene.js
* 3v3 Team match result screen.
* Shows winner, per-player stats (kills/deaths/assists/base damage),
* base HP summary, and options to rematch or return to menu.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
NET_MSG,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// Layout
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.35, 180);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
const BTN_GAP = 14;
const CENTER_X = SCREEN_WIDTH / 2;
// Team colors
const TEAM_A_COLOR = '#4A90D9';
const TEAM_B_COLOR = '#E94560';
const TeamResultScene = {
_winner: '',
_winReason: '',
_myTeam: '',
_didWin: false,
_teamABaseHp: 0,
_teamBBaseHp: 0,
_stats: {},
_players: [],
_elapsedTime: 0,
_teamId: '',
_animTimer: 0,
_battleMode: '3v3', // '1v1' or '3v3'
// Rematch state
_rematchRequested: false,
_rematchReadyCount: 0,
_rematchTotalCount: 0,
_networkManager: null,
_unsubscribers: [],
// Button rects
_rematchBtnRect: null,
_menuBtnRect: null,
_adDoubleBtnRect: null,
// Ad state
_adWatched: false,
_goldReward: 0,
// Scroll state for player list
_scrollY: 0,
enter(params) {
this._winner = (params && params.winner) || '';
this._winReason = (params && params.winReason) || 'base_destroyed';
this._myTeam = (params && params.myTeam) || 'A';
this._didWin = (params && params.didWin) || false;
this._teamABaseHp = (params && params.teamABaseHp) || 0;
this._teamBBaseHp = (params && params.teamBBaseHp) || 0;
this._stats = (params && params.stats) || {};
this._players = (params && params.players) || [];
this._elapsedTime = (params && params.elapsedTime) || 0;
this._teamId = (params && params.teamId) || '';
this._animTimer = 0;
this._scrollY = 0;
this._battleMode = (params && params.battleMode) || '3v3';
this._rematchRequested = false;
this._rematchReadyCount = 0;
this._rematchTotalCount = 0;
this._networkManager = GameGlobal.networkManager;
this._adWatched = false;
this._goldReward = 0;
// Calculate and award gold
this._calculateAndAwardGold();
this._setupNetworkEvents();
const btnY = SCREEN_HEIGHT * 0.88;
this._rematchBtnRect = {
x: CENTER_X - BTN_WIDTH - BTN_GAP / 2,
y: btnY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._menuBtnRect = {
x: CENTER_X + BTN_GAP / 2,
y: btnY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
// Double reward ad button (above rematch/menu buttons)
const adBtnY = btnY - BTN_HEIGHT - BTN_GAP;
this._adDoubleBtnRect = {
x: CENTER_X - BTN_WIDTH * 0.75,
y: adBtnY,
w: BTN_WIDTH * 1.5,
h: BTN_HEIGHT,
};
},
exit() {
this._cleanupNetworkEvents();
},
_setupNetworkEvents() {
this._cleanupNetworkEvents();
const nm = this._networkManager;
if (!nm) return;
const unsubs = [];
// Listen for rematch ready updates
unsubs.push(nm.on(NET_MSG.REMATCH_READY, (data) => {
console.log('[TeamResultScene] REMATCH_READY received:', JSON.stringify(data));
this._rematchReadyCount = data.readyCount || 0;
this._rematchTotalCount = data.totalCount || 0;
}));
// Listen for game start (rematch accepted, new game starting)
unsubs.push(nm.on(NET_MSG.GAME_START, (data) => {
console.log('[TeamResultScene] GAME_START received for rematch');
this._startRematchGame(data);
}));
unsubs.push(nm.on(NET_MSG.TEAM_GAME_START, (data) => {
console.log('[TeamResultScene] TEAM_GAME_START received for rematch');
this._startRematchGame(data);
}));
this._unsubscribers = unsubs;
},
_cleanupNetworkEvents() {
for (const unsub of this._unsubscribers) {
if (typeof unsub === 'function') unsub();
}
this._unsubscribers = [];
},
/**
* Calculate and award gold for team match.
* @private
*/
_calculateAndAwardGold() {
let gold = 50; // Base reward per requirements
// Find local player stats
const localPlayer = this._players.find(p => p.isLocal);
if (localPlayer) {
const stats = this._stats[localPlayer.playerId] || {};
gold += (stats.kills || 0) * 10;
gold += (stats.assists || 0) * 5;
}
// Victory bonus
if (this._didWin) {
gold += 50;
}
this._goldReward = gold;
// Award gold
if (gold > 0 && GameGlobal.currencyManager) {
GameGlobal.currencyManager.addGold(gold);
}
},
_startRematchGame(data) {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
const TeamGameScene = require('./TeamGameScene');
sm.register(SCENE.TEAM_GAME, TeamGameScene);
}
const myPlayerId = this._networkManager ? this._networkManager.playerId : 'local';
sm.switchTo(SCENE.TEAM_GAME, {
teamId: data.roomId || this._teamId,
roomId: data.roomId || this._teamId,
mapId: data.mapId || null,
teamA: data.teamA || [],
teamB: data.teamB || [],
teamABaseHp: data.teamABaseHp,
teamBBaseHp: data.teamBBaseHp,
myPlayerId,
battleMode: data.battleMode || this._battleMode,
});
},
update(dt) {
this._animTimer += dt;
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Top accent bar
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
gradient.addColorStop(0, '#0f3460');
gradient.addColorStop(0.5, '#e94560');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
// Title
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = '#AAAAAA';
ctx.fillText(t('teamResult.title'), CENTER_X, SCREEN_HEIGHT * 0.05);
// Winner announcement with pulsing effect
let resultText, resultColor;
if (this._didWin) {
resultText = t('teamResult.victory');
resultColor = '#00FF00';
} else {
resultText = t('teamResult.defeat');
resultColor = '#FF4444';
}
const scale = 1 + Math.sin(this._animTimer * 3) * 0.05;
ctx.save();
ctx.translate(CENTER_X, SCREEN_HEIGHT * 0.13);
ctx.scale(scale, scale);
ctx.fillStyle = resultColor;
ctx.font = 'bold 28px Arial';
ctx.fillText(resultText, 0, 0);
ctx.restore();
// Base HP summary
const hpY = SCREEN_HEIGHT * 0.2;
ctx.font = 'bold 12px Arial';
ctx.textAlign = 'center';
ctx.fillStyle = TEAM_A_COLOR;
ctx.fillText(t('teamResult.teamAHp', { hp: this._teamABaseHp }), CENTER_X - 70, hpY);
ctx.fillStyle = '#AAAAAA';
ctx.fillText('vs', CENTER_X, hpY);
ctx.fillStyle = TEAM_B_COLOR;
ctx.fillText(t('teamResult.teamBHp', { hp: this._teamBBaseHp }), CENTER_X + 70, hpY);
// Win reason
let reasonText = t('teamResult.baseDestroyed');
if (this._winReason === 'disconnected') {
reasonText = t('teamResult.disconnectedReason');
}
ctx.fillStyle = '#888888';
ctx.font = '10px Arial';
ctx.fillText(reasonText, CENTER_X, hpY + 16);
// Player stats table
this._renderStatsTable(ctx);
// Double reward ad button
if (!this._adWatched) {
this._drawButton(ctx, this._adDoubleBtnRect, t('result.adDouble') || '📺 Double Rewards');
}
// Gold reward display
if (this._goldReward > 0) {
const goldY = this._adDoubleBtnRect ? this._adDoubleBtnRect.y - 20 : SCREEN_HEIGHT * 0.82;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
const goldLabel = this._adWatched
? `🪙 +${this._goldReward} (${t('result.doubled') || '2x!'})`
: `🪙 +${this._goldReward}`;
ctx.fillText(goldLabel, CENTER_X, goldY);
}
// Buttons
if (this._rematchRequested) {
// Show waiting state on rematch button
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
this._drawButton(ctx, this._rematchBtnRect,
t('teamResult.rematchWaiting', { ready: this._rematchReadyCount, total: this._rematchTotalCount }) + dots,
true);
} else {
this._drawButton(ctx, this._rematchBtnRect, t('teamResult.rematch'));
}
this._drawButton(ctx, this._menuBtnRect, t('teamResult.backMenu'));
},
_renderStatsTable(ctx) {
const tableY = SCREEN_HEIGHT * 0.28;
const tableW = Math.min(SCREEN_WIDTH * 0.92, 400);
const tableX = CENTER_X - tableW / 2;
const rowH = 18;
const headerH = 22;
// Sort players: Team A first, then Team B; within team sort by kills desc
const teamAPlayers = this._players.filter(p => p.team === 'A');
const teamBPlayers = this._players.filter(p => p.team === 'B');
const sortByKills = (a, b) => {
const sa = this._stats[a.playerId] || {};
const sb = this._stats[b.playerId] || {};
return (sb.kills || 0) - (sa.kills || 0);
};
teamAPlayers.sort(sortByKills);
teamBPlayers.sort(sortByKills);
// Column positions
const cols = {
name: tableX + tableW * 0.22,
kills: tableX + tableW * 0.48,
deaths: tableX + tableW * 0.60,
assists: tableX + tableW * 0.72,
baseDmg: tableX + tableW * 0.88,
};
// Render Team A section
let y = tableY;
// Team A header
ctx.fillStyle = 'rgba(74, 144, 217, 0.15)';
ctx.fillRect(tableX, y, tableW, headerH);
ctx.fillStyle = TEAM_A_COLOR;
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(t('teamResult.teamAHeader') + (this._myTeam === 'A' ? t('teamResult.myTeamSuffix') : ''), tableX + 6, y + headerH / 2);
// Column headers
ctx.fillStyle = '#AAAAAA';
ctx.font = '9px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamResult.player'), cols.name, y + headerH / 2);
ctx.fillText(t('teamResult.k'), cols.kills, y + headerH / 2);
ctx.fillText(t('teamResult.d'), cols.deaths, y + headerH / 2);
ctx.fillText(t('teamResult.a'), cols.assists, y + headerH / 2);
ctx.fillText(t('teamResult.dmg'), cols.baseDmg, y + headerH / 2);
y += headerH;
// Team A players
for (const player of teamAPlayers) {
const stats = this._stats[player.playerId] || {};
const isLocal = player.isLocal;
ctx.fillStyle = isLocal ? 'rgba(255, 215, 0, 0.1)' : 'rgba(255,255,255,0.02)';
ctx.fillRect(tableX, y, tableW, rowH);
// Player name
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial';
ctx.textAlign = 'center';
const name = player.isBot ? t('teamResult.bot') : (player.playerId.length > 10 ? player.playerId.substring(0, 10) + '..' : player.playerId);
ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2);
// Stats
ctx.fillStyle = '#FFFFFF';
ctx.font = '10px Arial';
ctx.fillText(String(stats.kills || 0), cols.kills, y + rowH / 2);
ctx.fillText(String(stats.deaths || 0), cols.deaths, y + rowH / 2);
ctx.fillText(String(stats.assists || 0), cols.assists, y + rowH / 2);
ctx.fillText(String(stats.baseDamage || 0), cols.baseDmg, y + rowH / 2);
y += rowH;
}
// Separator
y += 4;
// Team B header
ctx.fillStyle = 'rgba(233, 69, 96, 0.15)';
ctx.fillRect(tableX, y, tableW, headerH);
ctx.fillStyle = TEAM_B_COLOR;
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'left';
ctx.fillText(t('teamResult.teamBHeader') + (this._myTeam === 'B' ? t('teamResult.myTeamSuffix') : ''), tableX + 6, y + headerH / 2);
// Column headers
ctx.fillStyle = '#AAAAAA';
ctx.font = '9px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamResult.player'), cols.name, y + headerH / 2);
ctx.fillText(t('teamResult.k'), cols.kills, y + headerH / 2);
ctx.fillText(t('teamResult.d'), cols.deaths, y + headerH / 2);
ctx.fillText(t('teamResult.a'), cols.assists, y + headerH / 2);
ctx.fillText(t('teamResult.dmg'), cols.baseDmg, y + headerH / 2);
y += headerH;
// Team B players
for (const player of teamBPlayers) {
const stats = this._stats[player.playerId] || {};
const isLocal = player.isLocal;
ctx.fillStyle = isLocal ? 'rgba(255, 215, 0, 0.1)' : 'rgba(255,255,255,0.02)';
ctx.fillRect(tableX, y, tableW, rowH);
// Player name
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
ctx.font = isLocal ? 'bold 10px Arial' : '10px Arial';
ctx.textAlign = 'center';
const name = player.isBot ? t('teamResult.bot') : (player.playerId.length > 10 ? player.playerId.substring(0, 10) + '..' : player.playerId);
ctx.fillText(name + (isLocal ? ' ★' : ''), cols.name, y + rowH / 2);
// Stats
ctx.fillStyle = '#FFFFFF';
ctx.font = '10px Arial';
ctx.fillText(String(stats.kills || 0), cols.kills, y + rowH / 2);
ctx.fillText(String(stats.deaths || 0), cols.deaths, y + rowH / 2);
ctx.fillText(String(stats.assists || 0), cols.assists, y + rowH / 2);
ctx.fillText(String(stats.baseDamage || 0), cols.baseDmg, y + rowH / 2);
y += rowH;
}
// Elapsed time display
if (this._elapsedTime > 0) {
y += 8;
const minutes = Math.floor(this._elapsedTime / 60);
const seconds = this._elapsedTime % 60;
ctx.fillStyle = '#666666';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.fillText(
t('teamResult.duration', { time: `${minutes}:${seconds.toString().padStart(2, '0')}` }),
CENTER_X,
y
);
}
// MVP highlight (player with most kills)
const allPlayers = [...teamAPlayers, ...teamBPlayers];
let mvp = null;
let maxKills = 0;
for (const p of allPlayers) {
const s = this._stats[p.playerId] || {};
if ((s.kills || 0) > maxKills) {
maxKills = s.kills || 0;
mvp = p;
}
}
if (mvp && maxKills > 0) {
y += 16;
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center';
const mvpName = mvp.isBot ? t('teamResult.bot').replace('🤖 ', '') : mvp.playerId;
ctx.fillText(t('teamResult.mvp', { name: mvpName, kills: maxKills }), CENTER_X, y);
}
// Rank points change
y += 18;
const basePoints = 20;
const mvpBonus = 5;
if (this._didWin) {
const isMvp = mvp && mvp.isLocal;
const points = basePoints + (isMvp ? mvpBonus : 0);
ctx.fillStyle = '#00FF00';
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamResult.rankUp', { points }) + (isMvp ? t('teamResult.mvpBonus') : ''), CENTER_X, y);
} else {
ctx.fillStyle = '#FF6347';
ctx.font = 'bold 11px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamResult.rankDown', { points: basePoints }), CENTER_X, y);
}
},
_drawButton(ctx, rect, label) {
if (!rect) return;
ctx.fillStyle = COLORS.MENU_BTN;
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
const r = 6;
ctx.beginPath();
ctx.moveTo(rect.x + r, rect.y);
ctx.lineTo(rect.x + rect.w - r, rect.y);
ctx.arcTo(rect.x + rect.w, rect.y, rect.x + rect.w, rect.y + r, r);
ctx.lineTo(rect.x + rect.w, rect.y + rect.h - r);
ctx.arcTo(rect.x + rect.w, rect.y + rect.h, rect.x + rect.w - r, rect.y + rect.h, r);
ctx.lineTo(rect.x + r, rect.y + rect.h);
ctx.arcTo(rect.x, rect.y + rect.h, rect.x, rect.y + rect.h - r, r);
ctx.lineTo(rect.x, rect.y + r);
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = COLORS.MENU_BTN_TEXT;
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
// Double reward ad button
if (!this._adWatched && this._hitTest(tx, ty, this._adDoubleBtnRect)) {
const AdManager = require('../managers/AdManager');
if (GameGlobal.adManager &&
GameGlobal.adManager.canShowScene(AdManager.AD_SCENE.DOUBLE_REWARD)) {
GameGlobal.adManager.showRewardedVideoForScene(
AdManager.AD_SCENE.DOUBLE_REWARD,
(completed) => {
if (completed) {
this._adWatched = true;
// Award bonus gold (double the original reward)
if (this._goldReward > 0 && GameGlobal.currencyManager) {
GameGlobal.currencyManager.addGold(this._goldReward);
this._goldReward *= 2; // Update display
}
console.log('[TeamResultScene] Double reward ad completed');
}
}
);
}
return;
}
// Rematch button -> send rematch request to server (reuse room)
if (this._hitTest(tx, ty, this._rematchBtnRect)) {
if (!this._rematchRequested) {
this._rematchRequested = true;
const nm = this._networkManager;
console.log(`[TeamResultScene] Rematch clicked. nm=${!!nm}, connected=${nm ? nm.connected : 'N/A'}, teamId=${this._teamId}`);
if (nm && nm.connected) {
nm.send(NET_MSG.REMATCH, { teamId: this._teamId });
console.log('[TeamResultScene] Rematch request sent');
} else {
// Not connected, fall back to creating a new room
this._rematchRequested = false;
const sm = GameGlobal.sceneManager;
if (this._battleMode === '1v1') {
if (!sm._scenes.has(SCENE.PVP_ROOM)) {
const RoomScene = require('./RoomScene');
sm.register(SCENE.PVP_ROOM, RoomScene);
}
sm.switchTo(SCENE.PVP_ROOM);
} else {
if (!sm._scenes.has(SCENE.TEAM_ROOM)) {
const TeamRoomScene = require('./TeamRoomScene');
sm.register(SCENE.TEAM_ROOM, TeamRoomScene);
}
sm.switchTo(SCENE.TEAM_ROOM);
}
}
}
return;
}
// Menu button -> disconnect and go to menu
if (this._hitTest(tx, ty, this._menuBtnRect)) {
// Show interstitial ad when leaving
if (GameGlobal.adManager) {
GameGlobal.adManager.showInterstitial();
}
if (GameGlobal.networkManager && GameGlobal.networkManager.connected) {
GameGlobal.networkManager.disconnect();
}
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
return;
}
},
};
module.exports = TeamResultScene;
+832
View File
@@ -0,0 +1,832 @@
/**
* TeamRoomScene.js
* 3v3 Team room UI scene.
* Supports team creation, joining, ready state, leader controls,
* matchmaking, and WeChat friend invitation.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
NET_MSG,
TEAM_SIZE,
SERVER_URL,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
// ============================================================
// Layout Constants
// ============================================================
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.35, 180);
const BTN_HEIGHT = Math.min(36, SCREEN_HEIGHT * 0.07);
const BTN_GAP = 10;
const CENTER_X = SCREEN_WIDTH / 2;
const SLOT_WIDTH = Math.min(SCREEN_WIDTH * 0.15, 80);
const SLOT_HEIGHT = Math.min(SCREEN_HEIGHT * 0.18, 90);
const SLOT_GAP = 8;
// ============================================================
// Team Room States
// ============================================================
const TEAM_STATE = {
MODE_SELECT: 'mode_select', // Choose: create team or solo match
JOINING: 'joining', // Auto-joining a team from invite
FORMING: 'forming', // Team room, waiting for members
MATCHING: 'matching', // In matchmaking queue
COUNTDOWN: 'countdown', // Match found, counting down
ERROR: 'error', // Error state
};
// ============================================================
// Team Room Scene
// ============================================================
const TeamRoomScene = {
_state: TEAM_STATE.MODE_SELECT,
_teamData: null, // { teamId, state, leaderId, teamA, teamB }
_errorMsg: '',
_animTimer: 0,
_matchTimer: 0, // Seconds elapsed in matching
_countdown: 3,
_countdownTimer: 0,
_networkManager: null,
_unsubscribers: [],
_isLeader: false,
_myPlayerId: null,
// Server URL (from global config)
_serverUrl: SERVER_URL,
// Button rects
_createTeamBtnRect: null,
_soloMatchBtnRect: null,
_backBtnRect: null,
_inviteBtnRect: null,
_matchBtnRect: null,
_readyBtnRect: null,
_disbandBtnRect: null,
_leaveBtnRect: null,
_cancelMatchBtnRect: null,
_slotRects: [],
_kickBtnRects: [],
enter(params) {
this._state = TEAM_STATE.MODE_SELECT;
this._teamData = null;
this._errorMsg = '';
this._animTimer = 0;
this._matchTimer = 0;
this._countdown = 3;
this._countdownTimer = 0;
this._networkManager = GameGlobal.networkManager;
this._myPlayerId = this._networkManager ? this._networkManager.playerId : 'player_' + Date.now();
this._isLeader = false;
this._buildLayout();
// Setup network events BEFORE auto-join so listeners are ready
this._setupNetworkEvents();
// If entering with a teamId (from invite card), auto-join
if (params && params.teamId) {
this._autoJoinTeam(params.teamId);
}
},
/**
* Update share content so that any share from the top-right menu
* always carries the current teamId.
* @private
*/
_updateShareContent() {
if (!this._teamData || !this._teamData.teamId) return;
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.setShareContent({
title: t('teamRoom.shareTitle'),
imageUrl: '',
query: `teamId=${this._teamData.teamId}`,
});
}
},
exit() {
this._cleanupNetworkEvents();
// Reset share content when leaving team room
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.resetShareContent();
}
},
_buildLayout() {
const modeY = SCREEN_HEIGHT * 0.4;
this._createTeamBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._soloMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: modeY + BTN_HEIGHT + BTN_GAP,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
this._backBtnRect = {
x: 10,
y: 10,
w: 60,
h: 30,
};
// Team member slots (5 slots in a row)
const totalSlotsWidth = TEAM_SIZE * SLOT_WIDTH + (TEAM_SIZE - 1) * SLOT_GAP;
const slotsStartX = CENTER_X - totalSlotsWidth / 2;
const slotsY = SCREEN_HEIGHT * 0.25;
this._slotRects = [];
this._kickBtnRects = [];
for (let i = 0; i < TEAM_SIZE; i++) {
const x = slotsStartX + i * (SLOT_WIDTH + SLOT_GAP);
this._slotRects.push({
x, y: slotsY, w: SLOT_WIDTH, h: SLOT_HEIGHT,
});
// Kick button (small X at top-right of slot)
this._kickBtnRects.push({
x: x + SLOT_WIDTH - 18, y: slotsY + 2, w: 16, h: 16,
});
}
// Action buttons (below slots)
const actionY = SCREEN_HEIGHT * 0.58;
const smallBtnW = BTN_WIDTH * 0.8;
this._inviteBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._matchBtnRect = {
x: CENTER_X + BTN_GAP / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._readyBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._disbandBtnRect = {
x: CENTER_X - smallBtnW - BTN_GAP / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._leaveBtnRect = {
x: CENTER_X - smallBtnW / 2,
y: actionY + BTN_HEIGHT + BTN_GAP,
w: smallBtnW,
h: BTN_HEIGHT,
};
this._cancelMatchBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: SCREEN_HEIGHT * 0.7,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
},
_setupNetworkEvents() {
this._cleanupNetworkEvents();
const nm = this._networkManager;
if (!nm) return;
const unsubs = [];
unsubs.push(nm.on(NET_MSG.TEAM_STATE, (data) => {
this._teamData = data;
this._isLeader = data.leaderId === this._myPlayerId;
if (data.state === 'forming') {
this._state = TEAM_STATE.FORMING;
} else if (data.state === 'matching') {
this._state = TEAM_STATE.MATCHING;
}
// Keep share content up-to-date with current teamId
this._updateShareContent();
}));
unsubs.push(nm.on(NET_MSG.TEAM_DISBAND, (data) => {
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
if (data.reason === 'kicked') {
this._errorMsg = t('common.kicked');
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on(NET_MSG.MATCH_FOUND, () => {
this._state = TEAM_STATE.COUNTDOWN;
this._countdown = 3;
this._countdownTimer = 0;
}));
unsubs.push(nm.on(NET_MSG.TEAM_GAME_START, (data) => {
this._startTeamGame(data);
}));
unsubs.push(nm.on(NET_MSG.ROOM_ERROR, (data) => {
this._errorMsg = data.message || 'Unknown error';
// Only switch to error state if not already in game transition
if (this._state !== TEAM_STATE.COUNTDOWN) {
this._state = TEAM_STATE.ERROR;
}
}));
unsubs.push(nm.on('error', () => {
this._errorMsg = t('common.connectFailed');
this._state = TEAM_STATE.ERROR;
}));
unsubs.push(nm.on('disconnected', () => {
if (this._state !== TEAM_STATE.MODE_SELECT) {
this._errorMsg = t('common.disconnected');
this._state = TEAM_STATE.ERROR;
}
}));
this._unsubscribers = unsubs;
},
_cleanupNetworkEvents() {
for (const unsub of this._unsubscribers) {
if (typeof unsub === 'function') unsub();
}
this._unsubscribers = [];
},
update(dt) {
this._animTimer += dt;
if (this._state === TEAM_STATE.MATCHING) {
this._matchTimer += dt;
}
if (this._state === TEAM_STATE.COUNTDOWN) {
this._countdownTimer += dt;
if (this._countdownTimer >= 1) {
this._countdownTimer -= 1;
this._countdown--;
}
}
},
_startTeamGame(data) {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.TEAM_GAME)) {
const TeamGameScene = require('./TeamGameScene');
sm.register(SCENE.TEAM_GAME, TeamGameScene);
}
sm.switchTo(SCENE.TEAM_GAME, {
teamId: this._teamData ? this._teamData.teamId : null,
mapId: data.mapId,
teamA: data.teamA,
teamB: data.teamB,
teamABaseHp: data.teamABaseHp,
teamBBaseHp: data.teamBBaseHp,
myPlayerId: this._myPlayerId,
});
},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Top accent bar
const gradient = ctx.createLinearGradient(0, 0, SCREEN_WIDTH, 0);
gradient.addColorStop(0, '#0f3460');
gradient.addColorStop(0.5, '#e94560');
gradient.addColorStop(1, '#0f3460');
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, SCREEN_WIDTH, 4);
// Back button
this._drawButton(ctx, this._backBtnRect, t('common.back'), false, 12);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 22px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('teamRoom.title'), CENTER_X, SCREEN_HEIGHT * 0.08);
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
this._renderModeSelect(ctx);
break;
case TEAM_STATE.JOINING:
this._renderJoining(ctx);
break;
case TEAM_STATE.FORMING:
this._renderForming(ctx);
break;
case TEAM_STATE.MATCHING:
this._renderMatching(ctx);
break;
case TEAM_STATE.COUNTDOWN:
this._renderCountdown(ctx);
break;
case TEAM_STATE.ERROR:
this._renderError(ctx);
break;
}
},
_renderJoining(ctx) {
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('teamRoom.joining') + dots, CENTER_X, SCREEN_HEIGHT * 0.45);
},
_renderModeSelect(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.chooseMode'), CENTER_X, SCREEN_HEIGHT * 0.28);
this._drawButton(ctx, this._createTeamBtnRect, t('teamRoom.createTeam'));
this._drawButton(ctx, this._soloMatchBtnRect, t('teamRoom.soloMatch'));
},
_renderForming(ctx) {
if (!this._teamData) return;
// Team ID display
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.teamId', { id: this._teamData.teamId }), CENTER_X, SCREEN_HEIGHT * 0.16);
// Render team member slots
const members = this._teamData.teamA || [];
for (let i = 0; i < TEAM_SIZE; i++) {
const rect = this._slotRects[i];
const member = members[i];
// Slot background
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = member ? (member.isLeader ? '#FFD700' : '#0f3460') : '#333333';
ctx.lineWidth = member && member.isLeader ? 3 : 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
// Avatar placeholder (circle)
const avatarR = Math.min(rect.w, rect.h) * 0.22;
const avatarCX = rect.x + rect.w / 2;
const avatarCY = rect.y + rect.h * 0.3;
ctx.fillStyle = member.isLeader ? '#FFD700' : '#4a90d9';
ctx.beginPath();
ctx.arc(avatarCX, avatarCY, avatarR, 0, Math.PI * 2);
ctx.fill();
// Player icon
ctx.fillStyle = '#FFFFFF';
ctx.font = `${avatarR}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('🎖', avatarCX, avatarCY);
// Leader badge
if (member.isLeader) {
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 9px Arial';
ctx.fillText(t('teamRoom.leader'), avatarCX, avatarCY + avatarR + 10);
}
// Player name (truncated)
ctx.fillStyle = '#FFFFFF';
ctx.font = '10px Arial';
const name = member.playerId.length > 8 ? member.playerId.substring(0, 8) + '..' : member.playerId;
ctx.fillText(name, avatarCX, rect.y + rect.h * 0.7);
// Ready state
if (!member.isLeader) {
ctx.fillStyle = member.ready ? '#00FF00' : '#FF6347';
ctx.font = 'bold 10px Arial';
ctx.fillText(member.ready ? t('teamRoom.ready') : t('teamRoom.notReady'), avatarCX, rect.y + rect.h * 0.88);
}
// Kick button (only for leader, not on self)
if (this._isLeader && !member.isLeader && member.playerId !== this._myPlayerId) {
const kickRect = this._kickBtnRects[i];
ctx.fillStyle = '#FF4444';
ctx.font = 'bold 12px Arial';
ctx.fillText('✕', kickRect.x + kickRect.w / 2, kickRect.y + kickRect.h / 2);
}
} else {
// Empty slot
ctx.fillStyle = '#555555';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('+', rect.x + rect.w / 2, rect.y + rect.h / 2);
ctx.fillStyle = '#666666';
ctx.font = '10px Arial';
ctx.fillText(t('teamRoom.emptySlot'), rect.x + rect.w / 2, rect.y + rect.h * 0.78);
}
}
// Action buttons based on role
if (this._isLeader) {
this._drawButton(ctx, this._inviteBtnRect, t('teamRoom.invite'));
// Match button: only enabled if all ready
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
this._drawButton(ctx, this._matchBtnRect, t('teamRoom.startMatch'), false, 14, allReady ? null : '#555555');
this._drawButton(ctx, this._disbandBtnRect, t('teamRoom.disband'), false, 12, '#8B0000');
} else {
// Member: ready/unready button
const myMember = members.find(m => m.playerId === this._myPlayerId);
const readyLabel = myMember && myMember.ready ? t('teamRoom.cancelReady') : t('teamRoom.readyBtn');
this._drawButton(ctx, this._readyBtnRect, readyLabel);
this._drawButton(ctx, this._leaveBtnRect, t('teamRoom.leaveTeam'), false, 12, '#8B0000');
}
},
_renderMatching(ctx) {
if (!this._teamData) return;
// Render team slots (smaller, at top)
const members = this._teamData.teamA || [];
for (let i = 0; i < TEAM_SIZE; i++) {
const rect = this._slotRects[i];
const member = members[i];
ctx.fillStyle = member ? '#1e3a5f' : '#0d1b2a';
ctx.strokeStyle = '#0f3460';
ctx.lineWidth = 1;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 8);
ctx.fill();
ctx.stroke();
if (member) {
ctx.fillStyle = '#FFFFFF';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const name = member.playerId.length > 8 ? member.playerId.substring(0, 8) + '..' : member.playerId;
ctx.fillText(name, rect.x + rect.w / 2, rect.y + rect.h / 2);
}
}
// Matching animation
const elapsed = Math.floor(this._matchTimer);
const dots = '.'.repeat(Math.floor(this._animTimer * 3) % 4);
ctx.fillStyle = '#FFFFFF';
ctx.font = '18px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.matching', { dots }), CENTER_X, SCREEN_HEIGHT * 0.55);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.waitTime', { seconds: elapsed }), CENTER_X, SCREEN_HEIGHT * 0.62);
// Cancel button (leader only)
if (this._isLeader) {
this._drawButton(ctx, this._cancelMatchBtnRect, t('teamRoom.cancelMatch'));
}
},
_renderCountdown(ctx) {
ctx.fillStyle = '#00FF00';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(t('teamRoom.matchFound'), CENTER_X, SCREEN_HEIGHT * 0.35);
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 64px Arial';
ctx.fillText(String(Math.max(1, this._countdown)), CENTER_X, SCREEN_HEIGHT * 0.52);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.enterBattle'), CENTER_X, SCREEN_HEIGHT * 0.65);
},
_renderError(ctx) {
ctx.fillStyle = '#FF4444';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMsg, CENTER_X, SCREEN_HEIGHT * 0.45);
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.fillText(t('teamRoom.tapBack'), CENTER_X, SCREEN_HEIGHT * 0.55);
},
_drawButton(ctx, rect, label, pressed, fontSize, bgColor) {
if (!rect) return;
const fs = fontSize || 14;
ctx.fillStyle = bgColor || (pressed ? '#0f3460' : COLORS.MENU_BTN);
ctx.strokeStyle = COLORS.MENU_BTN_BORDER;
ctx.lineWidth = 2;
this._drawRoundRect(ctx, rect.x, rect.y, rect.w, rect.h, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = pressed ? COLORS.MENU_TITLE : COLORS.MENU_BTN_TEXT;
ctx.font = `bold ${fs}px Arial`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(label, rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_drawRoundRect(ctx, x, y, w, h, r) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
},
_hitTest(tx, ty, rect) {
if (!rect) return false;
return tx >= rect.x && tx <= rect.x + rect.w && ty >= rect.y && ty <= rect.y + rect.h;
},
handleTouch(eventType, e) {
if (eventType !== 'touchstart') return;
const touch = e.touches[0];
const tx = touch.clientX;
const ty = touch.clientY;
// Back button
if (this._hitTest(tx, ty, this._backBtnRect)) {
this._goBack();
return;
}
switch (this._state) {
case TEAM_STATE.MODE_SELECT:
if (this._hitTest(tx, ty, this._createTeamBtnRect)) {
this._handleCreateTeam();
} else if (this._hitTest(tx, ty, this._soloMatchBtnRect)) {
this._handleSoloMatch();
}
break;
case TEAM_STATE.FORMING:
this._handleFormingTouch(tx, ty);
break;
case TEAM_STATE.MATCHING:
if (this._isLeader && this._hitTest(tx, ty, this._cancelMatchBtnRect)) {
this._handleCancelMatch();
}
break;
case TEAM_STATE.ERROR:
this._state = TEAM_STATE.MODE_SELECT;
this._errorMsg = '';
break;
}
},
_handleFormingTouch(tx, ty) {
if (!this._teamData) return;
if (this._isLeader) {
// Invite button
if (this._hitTest(tx, ty, this._inviteBtnRect)) {
this._handleInvite();
return;
}
// Match button
if (this._hitTest(tx, ty, this._matchBtnRect)) {
this._handleStartMatch();
return;
}
// Disband button
if (this._hitTest(tx, ty, this._disbandBtnRect)) {
this._handleDisband();
return;
}
// Kick buttons
const members = this._teamData.teamA || [];
for (let i = 0; i < members.length; i++) {
const member = members[i];
if (member && !member.isLeader && member.playerId !== this._myPlayerId) {
if (this._hitTest(tx, ty, this._kickBtnRects[i])) {
this._handleKick(member.playerId);
return;
}
}
}
} else {
// Ready button
if (this._hitTest(tx, ty, this._readyBtnRect)) {
this._handleReady();
return;
}
// Leave button
if (this._hitTest(tx, ty, this._leaveBtnRect)) {
this._handleLeave();
return;
}
}
},
async _handleCreateTeam() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
nm.send(NET_MSG.CREATE_TEAM, {});
},
async _handleSoloMatch() {
const nm = this._networkManager;
if (!nm) return;
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
this._matchTimer = 0;
// State will be updated by TEAM_STATE event from server
nm.send(NET_MSG.SOLO_MATCH, {});
},
async _autoJoinTeam(teamId) {
const nm = this._networkManager;
if (!nm) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
// Show a joining indicator
this._state = TEAM_STATE.JOINING;
this._errorMsg = '';
try {
if (!nm.connected) {
const ok = await nm.connect(this._serverUrl);
if (!ok) {
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
return;
}
}
this._myPlayerId = nm.playerId;
console.log(`[TeamRoom] Auto-joining team ${teamId} as ${this._myPlayerId}`);
nm.send(NET_MSG.JOIN_TEAM, { teamId });
} catch (e) {
console.error('[TeamRoom] Auto-join failed:', e);
this._errorMsg = t('common.cannotConnect');
this._state = TEAM_STATE.ERROR;
}
},
_handleInvite() {
if (!this._teamData) return;
const teamId = this._teamData.teamId;
const shareData = {
title: t('teamRoom.shareTitle'),
imageUrl: '',
query: `teamId=${teamId}`,
};
console.log(`[TeamRoom] Sharing invite with query: teamId=${teamId}`);
// WeChat mini-game policy: direct wx.shareAppMessage() calls are forbidden.
// Must use passive sharing via onShareAppMessage callback.
const shareManager = GameGlobal.shareManager;
if (shareManager) {
shareManager.triggerShare(shareData);
} else {
try {
wx.showToast({
title: '请点击右上角 ··· 转发给好友',
icon: 'none',
duration: 2500,
});
} catch (e) {
console.log('[TeamRoom] Share not available, teamId:', teamId);
}
}
},
_handleStartMatch() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
// Check all members are ready before sending
const members = this._teamData.teamA || [];
const allReady = members.length > 0 && members.every(m => m.ready || m.isLeader);
if (!allReady) return;
this._matchTimer = 0;
nm.send(NET_MSG.MATCH_START, {});
},
_handleCancelMatch() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.MATCH_CANCEL, {});
},
_handleReady() {
const nm = this._networkManager;
if (!nm || !this._teamData) return;
const myMember = (this._teamData.teamA || []).find(m => m.playerId === this._myPlayerId);
nm.send(NET_MSG.TEAM_READY, { ready: myMember ? !myMember.ready : true });
},
_handleKick(playerId) {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_KICK, { playerId });
},
_handleDisband() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.TEAM_DISBAND, {});
},
_handleLeave() {
const nm = this._networkManager;
if (!nm) return;
nm.send(NET_MSG.LEAVE_TEAM, {});
this._teamData = null;
this._state = TEAM_STATE.MODE_SELECT;
},
_goBack() {
// Leave team if in one
if (this._teamData) {
const nm = this._networkManager;
if (nm) {
if (this._state === TEAM_STATE.MATCHING && this._isLeader) {
// Cancel match first, then disband
nm.send(NET_MSG.MATCH_CANCEL, {});
}
if (this._isLeader) {
nm.send(NET_MSG.TEAM_DISBAND, {});
} else {
nm.send(NET_MSG.LEAVE_TEAM, {});
}
}
}
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
},
};
module.exports = TeamRoomScene;
+134
View File
@@ -0,0 +1,134 @@
/**
* FireButton.js
* Virtual fire button component positioned at the bottom-right of the screen.
* Overlaid as a separate layer above the map when they intersect.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
} = require('../base/GameGlobal');
class FireButton {
constructor() {
this.radius = 35;
// Anchored to screen bottom-right, shifted slightly towards upper-left
const padding = this.radius + 40;
this.cx = SCREEN_WIDTH - padding - 15; // right edge + extra leftward offset
this.cy = SCREEN_HEIGHT - padding - 30; // bottom edge + extra upward offset
this._pressed = false;
this._touchId = null;
this._fireCallback = null;
// Visual feedback
this._pressScale = 1;
// Touch area
this._touchAreaRadius = this.radius * 1.5;
}
/**
* Set the callback for when fire is triggered.
* @param {Function} cb
*/
onFire(cb) {
this._fireCallback = cb;
}
/**
* Handle touch events.
* @param {string} eventType
* @param {Touch} touch
* @returns {boolean} Whether this button consumed the touch.
*/
handleTouch(eventType, touch) {
const tx = touch.clientX;
const ty = touch.clientY;
if (eventType === 'touchstart') {
const dist = Math.sqrt((tx - this.cx) ** 2 + (ty - this.cy) ** 2);
if (dist <= this._touchAreaRadius) {
this._pressed = true;
this._touchId = touch.identifier;
this._pressScale = 0.85;
// Fire immediately on press
if (this._fireCallback) {
this._fireCallback();
}
return true;
}
return false;
}
if (eventType === 'touchend') {
if (this._pressed && touch.identifier === this._touchId) {
this._pressed = false;
this._touchId = null;
this._pressScale = 1;
return true;
}
return false;
}
return false;
}
/**
* Render the fire button.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
ctx.save();
ctx.globalAlpha = 0.5;
const r = this.radius * this._pressScale;
// Outer ring
ctx.strokeStyle = '#FF4444';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(this.cx, this.cy, r, 0, Math.PI * 2);
ctx.stroke();
// Inner fill
ctx.fillStyle = this._pressed ? '#FF6666' : '#CC3333';
ctx.beginPath();
ctx.arc(this.cx, this.cy, r * 0.75, 0, Math.PI * 2);
ctx.fill();
// Fire icon (crosshair)
ctx.globalAlpha = 0.8;
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
const crossSize = r * 0.35;
// Horizontal line
ctx.beginPath();
ctx.moveTo(this.cx - crossSize, this.cy);
ctx.lineTo(this.cx + crossSize, this.cy);
ctx.stroke();
// Vertical line
ctx.beginPath();
ctx.moveTo(this.cx, this.cy - crossSize);
ctx.lineTo(this.cx, this.cy + crossSize);
ctx.stroke();
// Center dot
ctx.fillStyle = '#FFFFFF';
ctx.beginPath();
ctx.arc(this.cx, this.cy, 3, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/** Whether the button is currently pressed. */
get pressed() {
return this._pressed;
}
}
module.exports = FireButton;
+206
View File
@@ -0,0 +1,206 @@
/**
* Joystick.js
* Virtual joystick component for touch-based directional control.
* Positioned at the bottom-left of the screen; overlaid as a separate layer
* above the map when they intersect.
*/
const {
DIRECTION,
SCREEN_WIDTH,
SCREEN_HEIGHT,
} = require('../base/GameGlobal');
class Joystick {
constructor() {
// Position and size — anchored to screen bottom-left, shifted slightly
// towards the upper-right for comfortable thumb reach
this.radius = 50;
this.innerRadius = 20;
const padding = this.radius + 30;
this.cx = padding + 15; // left edge + rightward offset
this.cy = SCREEN_HEIGHT - padding - 30; // bottom edge + upward offset
// State
this._active = false;
this._touchId = null;
this._touchX = 0;
this._touchY = 0;
this._direction = -1; // -1 = no direction
this._dx = 0;
this._dy = 0;
// Touch area (larger than visual for easier use)
this._touchAreaRadius = this.radius * 2;
}
/**
* Handle touch events.
* @param {string} eventType
* @param {Touch} touch - Single touch object.
* @returns {boolean} Whether this joystick consumed the touch.
*/
handleTouch(eventType, touch) {
const tx = touch.clientX;
const ty = touch.clientY;
if (eventType === 'touchstart') {
// Check if touch is within joystick area
const dist = Math.sqrt((tx - this.cx) ** 2 + (ty - this.cy) ** 2);
if (dist <= this._touchAreaRadius) {
this._active = true;
this._touchId = touch.identifier;
this._updateDirection(tx, ty);
return true;
}
return false;
}
if (eventType === 'touchmove') {
if (this._active && touch.identifier === this._touchId) {
this._updateDirection(tx, ty);
return true;
}
return false;
}
if (eventType === 'touchend') {
if (this._active && touch.identifier === this._touchId) {
this._active = false;
this._touchId = null;
this._direction = -1;
this._dx = 0;
this._dy = 0;
return true;
}
return false;
}
return false;
}
/**
* Calculate direction from touch position.
* @private
*/
_updateDirection(tx, ty) {
this._touchX = tx;
this._touchY = ty;
const dx = tx - this.cx;
const dy = ty - this.cy;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 10) {
this._direction = -1;
this._dx = 0;
this._dy = 0;
return;
}
// Clamp to radius for visual
const clampDist = Math.min(dist, this.radius);
this._dx = (dx / dist) * clampDist;
this._dy = (dy / dist) * clampDist;
// Determine 4-direction based on angle
const angle = Math.atan2(dy, dx);
// Right: -45° to 45°, Down: 45° to 135°, Left: 135° to -135°, Up: -135° to -45°
if (angle >= -Math.PI / 4 && angle < Math.PI / 4) {
this._direction = DIRECTION.RIGHT;
} else if (angle >= Math.PI / 4 && angle < Math.PI * 3 / 4) {
this._direction = DIRECTION.DOWN;
} else if (angle >= -Math.PI * 3 / 4 && angle < -Math.PI / 4) {
this._direction = DIRECTION.UP;
} else {
this._direction = DIRECTION.LEFT;
}
}
/**
* Render the joystick.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
ctx.save();
ctx.globalAlpha = 0.3;
// Outer circle
ctx.fillStyle = '#333333';
ctx.strokeStyle = '#666666';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(this.cx, this.cy, this.radius, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Direction indicators
ctx.globalAlpha = 0.3;
ctx.fillStyle = '#FFFFFF';
const arrowSize = 8;
// Up arrow
this._drawArrow(ctx, this.cx, this.cy - this.radius * 0.6, DIRECTION.UP, arrowSize);
// Down arrow
this._drawArrow(ctx, this.cx, this.cy + this.radius * 0.6, DIRECTION.DOWN, arrowSize);
// Left arrow
this._drawArrow(ctx, this.cx - this.radius * 0.6, this.cy, DIRECTION.LEFT, arrowSize);
// Right arrow
this._drawArrow(ctx, this.cx + this.radius * 0.6, this.cy, DIRECTION.RIGHT, arrowSize);
// Inner knob
ctx.globalAlpha = 0.6;
const knobX = this._active ? this.cx + this._dx : this.cx;
const knobY = this._active ? this.cy + this._dy : this.cy;
ctx.fillStyle = this._active ? '#FFD700' : '#888888';
ctx.beginPath();
ctx.arc(knobX, knobY, this.innerRadius, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
/**
* Draw a small directional arrow.
* @private
*/
_drawArrow(ctx, x, y, dir, size) {
ctx.beginPath();
switch (dir) {
case DIRECTION.UP:
ctx.moveTo(x, y - size);
ctx.lineTo(x - size, y + size);
ctx.lineTo(x + size, y + size);
break;
case DIRECTION.DOWN:
ctx.moveTo(x, y + size);
ctx.lineTo(x - size, y - size);
ctx.lineTo(x + size, y - size);
break;
case DIRECTION.LEFT:
ctx.moveTo(x - size, y);
ctx.lineTo(x + size, y - size);
ctx.lineTo(x + size, y + size);
break;
case DIRECTION.RIGHT:
ctx.moveTo(x + size, y);
ctx.lineTo(x - size, y - size);
ctx.lineTo(x - size, y + size);
break;
}
ctx.closePath();
ctx.fill();
}
/** Current direction (-1 if idle). */
get direction() {
return this._direction;
}
/** Whether the joystick is being touched. */
get active() {
return this._active;
}
}
module.exports = Joystick;
+164
View File
@@ -0,0 +1,164 @@
/**
* TutorialOverlay.js
* New player tutorial overlay shown on first play.
* Displays 2-3 step instructions for controls.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
MAP_OFFSET_X,
MAP_WIDTH,
} = require('../base/GameGlobal');
class TutorialOverlay {
constructor() {
this._active = false;
this._step = 0;
this._totalSteps = 3;
this._steps = [
{
title: '移动坦克',
desc: '拖动左下角的摇杆\n控制坦克上下左右移动',
highlight: 'joystick',
},
{
title: '发射子弹',
desc: '点击右下角的按钮\n向前方发射子弹',
highlight: 'fire',
},
{
title: '保护基地',
desc: '消灭所有敌人\n不要让基地被摧毁!',
highlight: 'base',
},
];
}
/**
* Show the tutorial.
*/
show() {
this._active = true;
this._step = 0;
}
/**
* Hide the tutorial.
*/
hide() {
this._active = false;
}
/** Whether the tutorial is active. */
get active() {
return this._active;
}
/**
* Handle touch to advance steps.
* @returns {boolean} Whether the tutorial consumed the touch.
*/
handleTouch() {
if (!this._active) return false;
this._step++;
if (this._step >= this._totalSteps) {
this._active = false;
}
return true;
}
/**
* Render the tutorial overlay.
* @param {CanvasRenderingContext2D} ctx
*/
render(ctx) {
if (!this._active) return;
const step = this._steps[this._step];
// Semi-transparent overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const cx = SCREEN_WIDTH / 2;
const cy = SCREEN_HEIGHT / 2;
// Step indicator
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${this._step + 1} / ${this._totalSteps}`, cx, cy - 80);
// Title
ctx.fillStyle = '#FFD700';
ctx.font = 'bold 24px Arial';
ctx.fillText(step.title, cx, cy - 40);
// Description (multi-line)
ctx.fillStyle = '#FFFFFF';
ctx.font = '16px Arial';
const lines = step.desc.split('\n');
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], cx, cy + 10 + i * 24);
}
// Highlight area indicator
this._drawHighlight(ctx, step.highlight);
// Tap to continue
ctx.fillStyle = '#888888';
ctx.font = '13px Arial';
ctx.fillText('点击屏幕继续', cx, SCREEN_HEIGHT - 60);
}
/**
* Draw a highlight circle around the relevant UI element.
* @private
*/
_drawHighlight(ctx, type) {
ctx.save();
ctx.strokeStyle = '#FFD700';
ctx.lineWidth = 3;
ctx.setLineDash([8, 4]);
switch (type) {
case 'joystick':
ctx.beginPath();
ctx.arc(Math.floor(MAP_OFFSET_X / 2), SCREEN_HEIGHT - 100, 65, 0, Math.PI * 2);
ctx.stroke();
break;
case 'fire': {
const rightAreaStart = MAP_OFFSET_X + MAP_WIDTH;
ctx.beginPath();
ctx.arc(Math.floor(rightAreaStart + (SCREEN_WIDTH - rightAreaStart) / 2), SCREEN_HEIGHT - 100, 50, 0, Math.PI * 2);
ctx.stroke();
break;
}
case 'base': {
// Arrow pointing to base area
const baseCx = SCREEN_WIDTH / 2;
ctx.beginPath();
ctx.moveTo(baseCx, SCREEN_HEIGHT * 0.7);
ctx.lineTo(baseCx, SCREEN_HEIGHT * 0.8);
ctx.stroke();
ctx.fillStyle = '#FFD700';
ctx.beginPath();
ctx.moveTo(baseCx - 8, SCREEN_HEIGHT * 0.8);
ctx.lineTo(baseCx + 8, SCREEN_HEIGHT * 0.8);
ctx.lineTo(baseCx, SCREEN_HEIGHT * 0.8 + 10);
ctx.closePath();
ctx.fill();
break;
}
}
ctx.setLineDash([]);
ctx.restore();
}
}
module.exports = TutorialOverlay;
+59
View File
@@ -0,0 +1,59 @@
{
"description": "Tank War - WeChat Mini Game",
"packOptions": {
"ignore": [
{
"value": ".codebuddy",
"type": "folder"
},
{
"value": ".gitignore",
"type": "file"
}
],
"include": []
},
"setting": {
"urlCheck": false,
"es6": true,
"enhance": true,
"postcss": true,
"preloadBackgroundData": false,
"minified": true,
"newFeature": true,
"coverView": true,
"nodeModules": false,
"autoAudits": false,
"showShadowRootInWxmlPanel": true,
"scopeDataCheck": false,
"uglifyFileName": false,
"checkInvalidKey": true,
"checkSiteMap": true,
"uploadWithSourceMap": true,
"compileHotReLoad": false,
"babelSetting": {
"ignore": [],
"disablePlugins": [],
"outputPath": ""
},
"compileWorklet": false,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"minifyWXML": true,
"localPlugins": false,
"disableUseStrict": false,
"useCompilerPlugins": false,
"condition": false,
"swc": false,
"disableSWC": true
},
"compileType": "game",
"libVersion": "2.25.0",
"appid": "wx3527fe2fd49db523",
"projectname": "tankwar",
"condition": {},
"simulatorPluginLibVersion": {},
"isGameTourist": false,
"editorSetting": {}
}
+41
View File
@@ -0,0 +1,41 @@
{
"libVersion": "3.15.1",
"projectname": "tankwar",
"condition": {
"game": {
"list": [
{
"name": "测试加入房间",
"pathName": "",
"query": "teamId=T30638",
"scene": null,
"launchMode": "defalut"
},
{
"pathName": "",
"name": "模拟房主",
"query": "",
"scene": null
}
]
}
},
"setting": {
"urlCheck": false,
"coverView": true,
"lazyloadPlaceholderEnable": false,
"skylineRenderEnable": false,
"preloadBackgroundData": false,
"autoAudits": false,
"useApiHook": true,
"useApiHostProcess": true,
"showShadowRootInWxmlPanel": true,
"useStaticServer": false,
"useLanDebug": false,
"showES6CompileOption": false,
"compileHotReLoad": false,
"checkInvalidKey": true,
"ignoreDevUnusedFiles": true,
"bigPackageSizeSupport": false
}
}
+1607
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "tankwar-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+20
View File
@@ -0,0 +1,20 @@
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
Copyright (c) 2013 Arnout Kazemier and contributors
Copyright (c) 2016 Luigi Pinca and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+548
View File
@@ -0,0 +1,548 @@
# ws: a Node.js WebSocket library
[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws)
[![CI](https://img.shields.io/github/actions/workflow/status/websockets/ws/ci.yml?branch=master&label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws)
ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and
server implementation.
Passes the quite extensive Autobahn test suite: [server][server-report],
[client][client-report].
**Note**: This module does not work in the browser. The client in the docs is a
reference to a backend with the role of a client in the WebSocket communication.
Browser clients must use the native
[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
object. To make the same code work seamlessly on Node.js and the browser, you
can use one of the many wrappers available on npm, like
[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws).
## Table of Contents
- [Protocol support](#protocol-support)
- [Installing](#installing)
- [Opt-in for performance](#opt-in-for-performance)
- [Legacy opt-in for performance](#legacy-opt-in-for-performance)
- [API docs](#api-docs)
- [WebSocket compression](#websocket-compression)
- [Usage examples](#usage-examples)
- [Sending and receiving text data](#sending-and-receiving-text-data)
- [Sending binary data](#sending-binary-data)
- [Simple server](#simple-server)
- [External HTTP/S server](#external-https-server)
- [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server)
- [Client authentication](#client-authentication)
- [Server broadcast](#server-broadcast)
- [Round-trip time](#round-trip-time)
- [Use the Node.js streams API](#use-the-nodejs-streams-api)
- [Other examples](#other-examples)
- [FAQ](#faq)
- [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client)
- [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections)
- [How to connect via a proxy?](#how-to-connect-via-a-proxy)
- [Changelog](#changelog)
- [License](#license)
## Protocol support
- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`)
- **HyBi drafts 13-17** (Current default, alternatively option
`protocolVersion: 13`)
## Installing
```
npm install ws
```
### Opt-in for performance
[bufferutil][] is an optional module that can be installed alongside the ws
module:
```
npm install --save-optional bufferutil
```
This is a binary addon that improves the performance of certain operations such
as masking and unmasking the data payload of the WebSocket frames. Prebuilt
binaries are available for the most popular platforms, so you don't necessarily
need to have a C++ compiler installed on your machine.
To force ws to not use bufferutil, use the
[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This
can be useful to enhance security in systems where a user can put a package in
the package search path of an application of another user, due to how the
Node.js resolver algorithm works.
#### Legacy opt-in for performance
If you are running on an old version of Node.js (prior to v18.14.0), ws also
supports the [utf-8-validate][] module:
```
npm install --save-optional utf-8-validate
```
This contains a binary polyfill for [`buffer.isUtf8()`][].
To force ws not to use utf-8-validate, use the
[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable.
## API docs
See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and
utility functions.
## WebSocket compression
ws supports the [permessage-deflate extension][permessage-deflate] which enables
the client and server to negotiate a compression algorithm and its parameters,
and then selectively apply it to the data payloads of each WebSocket message.
The extension is disabled by default on the server and enabled by default on the
client. It adds a significant overhead in terms of performance and memory
consumption so we suggest to enable it only if it is really needed.
Note that Node.js has a variety of issues with high-performance compression,
where increased concurrency, especially on Linux, can lead to [catastrophic
memory fragmentation][node-zlib-bug] and slow performance. If you intend to use
permessage-deflate in production, it is worthwhile to set up a test
representative of your workload and ensure Node.js/zlib will handle it with
acceptable performance and memory usage.
Tuning of permessage-deflate can be done via the options defined below. You can
also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly
into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs].
See [the docs][ws-server-options] for more options.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({
port: 8080,
perMessageDeflate: {
zlibDeflateOptions: {
// See zlib defaults.
chunkSize: 1024,
memLevel: 7,
level: 3
},
zlibInflateOptions: {
chunkSize: 10 * 1024
},
// Other options settable:
clientNoContextTakeover: true, // Defaults to negotiated value.
serverNoContextTakeover: true, // Defaults to negotiated value.
serverMaxWindowBits: 10, // Defaults to negotiated value.
// Below options specified as default values.
concurrencyLimit: 10, // Limits zlib concurrency for perf.
threshold: 1024 // Size (in bytes) below which messages
// should not be compressed if context takeover is disabled.
}
});
```
The client will only use the extension if it is supported and enabled on the
server. To always disable the extension on the client, set the
`perMessageDeflate` option to `false`.
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path', {
perMessageDeflate: false
});
```
## Usage examples
### Sending and receiving text data
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path');
ws.on('error', console.error);
ws.on('open', function open() {
ws.send('something');
});
ws.on('message', function message(data) {
console.log('received: %s', data);
});
```
### Sending binary data
```js
import WebSocket from 'ws';
const ws = new WebSocket('ws://www.host.com/path');
ws.on('error', console.error);
ws.on('open', function open() {
const array = new Float32Array(5);
for (var i = 0; i < array.length; ++i) {
array[i] = i / 2;
}
ws.send(array);
});
```
### Simple server
```js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log('received: %s', data);
});
ws.send('something');
});
```
### External HTTP/S server
```js
import { createServer } from 'https';
import { readFileSync } from 'fs';
import { WebSocketServer } from 'ws';
const server = createServer({
cert: readFileSync('/path/to/cert.pem'),
key: readFileSync('/path/to/key.pem')
});
const wss = new WebSocketServer({ server });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log('received: %s', data);
});
ws.send('something');
});
server.listen(8080);
```
### Multiple servers sharing a single HTTP/S server
```js
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
const server = createServer();
const wss1 = new WebSocketServer({ noServer: true });
const wss2 = new WebSocketServer({ noServer: true });
wss1.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
wss2.on('connection', function connection(ws) {
ws.on('error', console.error);
// ...
});
server.on('upgrade', function upgrade(request, socket, head) {
const { pathname } = new URL(request.url, 'wss://base.url');
if (pathname === '/foo') {
wss1.handleUpgrade(request, socket, head, function done(ws) {
wss1.emit('connection', ws, request);
});
} else if (pathname === '/bar') {
wss2.handleUpgrade(request, socket, head, function done(ws) {
wss2.emit('connection', ws, request);
});
} else {
socket.destroy();
}
});
server.listen(8080);
```
### Client authentication
```js
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
function onSocketError(err) {
console.error(err);
}
const server = createServer();
const wss = new WebSocketServer({ noServer: true });
wss.on('connection', function connection(ws, request, client) {
ws.on('error', console.error);
ws.on('message', function message(data) {
console.log(`Received message ${data} from user ${client}`);
});
});
server.on('upgrade', function upgrade(request, socket, head) {
socket.on('error', onSocketError);
// This function is not defined on purpose. Implement it with your own logic.
authenticate(request, function next(err, client) {
if (err || !client) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
socket.removeListener('error', onSocketError);
wss.handleUpgrade(request, socket, head, function done(ws) {
wss.emit('connection', ws, request, client);
});
});
});
server.listen(8080);
```
Also see the provided [example][session-parse-example] using `express-session`.
### Server broadcast
A client WebSocket broadcasting to all connected WebSocket clients, including
itself.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
if (client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});
```
A client WebSocket broadcasting to every other connected WebSocket clients,
excluding itself.
```js
import WebSocket, { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.on('error', console.error);
ws.on('message', function message(data, isBinary) {
wss.clients.forEach(function each(client) {
if (client !== ws && client.readyState === WebSocket.OPEN) {
client.send(data, { binary: isBinary });
}
});
});
});
```
### Round-trip time
```js
import WebSocket from 'ws';
const ws = new WebSocket('wss://websocket-echo.com/');
ws.on('error', console.error);
ws.on('open', function open() {
console.log('connected');
ws.send(Date.now());
});
ws.on('close', function close() {
console.log('disconnected');
});
ws.on('message', function message(data) {
console.log(`Round-trip time: ${Date.now() - data} ms`);
setTimeout(function timeout() {
ws.send(Date.now());
}, 500);
});
```
### Use the Node.js streams API
```js
import WebSocket, { createWebSocketStream } from 'ws';
const ws = new WebSocket('wss://websocket-echo.com/');
const duplex = createWebSocketStream(ws, { encoding: 'utf8' });
duplex.on('error', console.error);
duplex.pipe(process.stdout);
process.stdin.pipe(duplex);
```
### Other examples
For a full example with a browser client communicating with a ws server, see the
examples folder.
Otherwise, see the test cases.
## FAQ
### How to get the IP address of the client?
The remote IP address can be obtained from the raw socket.
```js
import { WebSocketServer } from 'ws';
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws, req) {
const ip = req.socket.remoteAddress;
ws.on('error', console.error);
});
```
When the server runs behind a proxy like NGINX, the de-facto standard is to use
the `X-Forwarded-For` header.
```js
wss.on('connection', function connection(ws, req) {
const ip = req.headers['x-forwarded-for'].split(',')[0].trim();
ws.on('error', console.error);
});
```
### How to detect and close broken connections?
Sometimes, the link between the server and the client can be interrupted in a
way that keeps both the server and the client unaware of the broken state of the
connection (e.g. when pulling the cord).
In these cases, ping messages can be used as a means to verify that the remote
endpoint is still responsive.
```js
import { WebSocketServer } from 'ws';
function heartbeat() {
this.isAlive = true;
}
const wss = new WebSocketServer({ port: 8080 });
wss.on('connection', function connection(ws) {
ws.isAlive = true;
ws.on('error', console.error);
ws.on('pong', heartbeat);
});
const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping();
});
}, 30000);
wss.on('close', function close() {
clearInterval(interval);
});
```
Pong messages are automatically sent in response to ping messages as required by
the spec.
Just like the server example above, your clients might as well lose connection
without knowing it. You might want to add a ping listener on your clients to
prevent that. A simple implementation would be:
```js
import WebSocket from 'ws';
function heartbeat() {
clearTimeout(this.pingTimeout);
// Use `WebSocket#terminate()`, which immediately destroys the connection,
// instead of `WebSocket#close()`, which waits for the close timer.
// Delay should be equal to the interval at which your server
// sends out pings plus a conservative assumption of the latency.
this.pingTimeout = setTimeout(() => {
this.terminate();
}, 30000 + 1000);
}
const client = new WebSocket('wss://websocket-echo.com/');
client.on('error', console.error);
client.on('open', heartbeat);
client.on('ping', heartbeat);
client.on('close', function clear() {
clearTimeout(this.pingTimeout);
});
```
### How to connect via a proxy?
Use a custom `http.Agent` implementation like [https-proxy-agent][] or
[socks-proxy-agent][].
## Changelog
We're using the GitHub [releases][changelog] for changelog entries.
## License
[MIT](LICENSE)
[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input
[bufferutil]: https://github.com/websockets/bufferutil
[changelog]: https://github.com/websockets/ws/releases
[client-report]: http://websockets.github.io/ws/autobahn/clients/
[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent
[node-zlib-bug]: https://github.com/nodejs/node/issues/8871
[node-zlib-deflaterawdocs]:
https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options
[permessage-deflate]: https://tools.ietf.org/html/rfc7692
[server-report]: http://websockets.github.io/ws/autobahn/servers/
[session-parse-example]: ./examples/express-session-parse
[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent
[utf-8-validate]: https://github.com/websockets/utf-8-validate
[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback
+8
View File
@@ -0,0 +1,8 @@
'use strict';
module.exports = function () {
throw new Error(
'ws does not work in the browser. Browser clients must use the native ' +
'WebSocket object'
);
};
+22
View File
@@ -0,0 +1,22 @@
'use strict';
const createWebSocketStream = require('./lib/stream');
const extension = require('./lib/extension');
const PerMessageDeflate = require('./lib/permessage-deflate');
const Receiver = require('./lib/receiver');
const Sender = require('./lib/sender');
const subprotocol = require('./lib/subprotocol');
const WebSocket = require('./lib/websocket');
const WebSocketServer = require('./lib/websocket-server');
WebSocket.createWebSocketStream = createWebSocketStream;
WebSocket.extension = extension;
WebSocket.PerMessageDeflate = PerMessageDeflate;
WebSocket.Receiver = Receiver;
WebSocket.Sender = Sender;
WebSocket.Server = WebSocketServer;
WebSocket.subprotocol = subprotocol;
WebSocket.WebSocket = WebSocket;
WebSocket.WebSocketServer = WebSocketServer;
module.exports = WebSocket;
+131
View File
@@ -0,0 +1,131 @@
'use strict';
const { EMPTY_BUFFER } = require('./constants');
const FastBuffer = Buffer[Symbol.species];
/**
* Merges an array of buffers into a new buffer.
*
* @param {Buffer[]} list The array of buffers to concat
* @param {Number} totalLength The total length of buffers in the list
* @return {Buffer} The resulting buffer
* @public
*/
function concat(list, totalLength) {
if (list.length === 0) return EMPTY_BUFFER;
if (list.length === 1) return list[0];
const target = Buffer.allocUnsafe(totalLength);
let offset = 0;
for (let i = 0; i < list.length; i++) {
const buf = list[i];
target.set(buf, offset);
offset += buf.length;
}
if (offset < totalLength) {
return new FastBuffer(target.buffer, target.byteOffset, offset);
}
return target;
}
/**
* Masks a buffer using the given mask.
*
* @param {Buffer} source The buffer to mask
* @param {Buffer} mask The mask to use
* @param {Buffer} output The buffer where to store the result
* @param {Number} offset The offset at which to start writing
* @param {Number} length The number of bytes to mask.
* @public
*/
function _mask(source, mask, output, offset, length) {
for (let i = 0; i < length; i++) {
output[offset + i] = source[i] ^ mask[i & 3];
}
}
/**
* Unmasks a buffer using the given mask.
*
* @param {Buffer} buffer The buffer to unmask
* @param {Buffer} mask The mask to use
* @public
*/
function _unmask(buffer, mask) {
for (let i = 0; i < buffer.length; i++) {
buffer[i] ^= mask[i & 3];
}
}
/**
* Converts a buffer to an `ArrayBuffer`.
*
* @param {Buffer} buf The buffer to convert
* @return {ArrayBuffer} Converted buffer
* @public
*/
function toArrayBuffer(buf) {
if (buf.length === buf.buffer.byteLength) {
return buf.buffer;
}
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.length);
}
/**
* Converts `data` to a `Buffer`.
*
* @param {*} data The data to convert
* @return {Buffer} The buffer
* @throws {TypeError}
* @public
*/
function toBuffer(data) {
toBuffer.readOnly = true;
if (Buffer.isBuffer(data)) return data;
let buf;
if (data instanceof ArrayBuffer) {
buf = new FastBuffer(data);
} else if (ArrayBuffer.isView(data)) {
buf = new FastBuffer(data.buffer, data.byteOffset, data.byteLength);
} else {
buf = Buffer.from(data);
toBuffer.readOnly = false;
}
return buf;
}
module.exports = {
concat,
mask: _mask,
toArrayBuffer,
toBuffer,
unmask: _unmask
};
/* istanbul ignore else */
if (!process.env.WS_NO_BUFFER_UTIL) {
try {
const bufferUtil = require('bufferutil');
module.exports.mask = function (source, mask, output, offset, length) {
if (length < 48) _mask(source, mask, output, offset, length);
else bufferUtil.mask(source, mask, output, offset, length);
};
module.exports.unmask = function (buffer, mask) {
if (buffer.length < 32) _unmask(buffer, mask);
else bufferUtil.unmask(buffer, mask);
};
} catch (e) {
// Continue regardless of the error.
}
}
+19
View File
@@ -0,0 +1,19 @@
'use strict';
const BINARY_TYPES = ['nodebuffer', 'arraybuffer', 'fragments'];
const hasBlob = typeof Blob !== 'undefined';
if (hasBlob) BINARY_TYPES.push('blob');
module.exports = {
BINARY_TYPES,
CLOSE_TIMEOUT: 30000,
EMPTY_BUFFER: Buffer.alloc(0),
GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
hasBlob,
kForOnEventAttribute: Symbol('kIsForOnEventAttribute'),
kListener: Symbol('kListener'),
kStatusCode: Symbol('status-code'),
kWebSocket: Symbol('websocket'),
NOOP: () => {}
};
+292
View File
@@ -0,0 +1,292 @@
'use strict';
const { kForOnEventAttribute, kListener } = require('./constants');
const kCode = Symbol('kCode');
const kData = Symbol('kData');
const kError = Symbol('kError');
const kMessage = Symbol('kMessage');
const kReason = Symbol('kReason');
const kTarget = Symbol('kTarget');
const kType = Symbol('kType');
const kWasClean = Symbol('kWasClean');
/**
* Class representing an event.
*/
class Event {
/**
* Create a new `Event`.
*
* @param {String} type The name of the event
* @throws {TypeError} If the `type` argument is not specified
*/
constructor(type) {
this[kTarget] = null;
this[kType] = type;
}
/**
* @type {*}
*/
get target() {
return this[kTarget];
}
/**
* @type {String}
*/
get type() {
return this[kType];
}
}
Object.defineProperty(Event.prototype, 'target', { enumerable: true });
Object.defineProperty(Event.prototype, 'type', { enumerable: true });
/**
* Class representing a close event.
*
* @extends Event
*/
class CloseEvent extends Event {
/**
* Create a new `CloseEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {Number} [options.code=0] The status code explaining why the
* connection was closed
* @param {String} [options.reason=''] A human-readable string explaining why
* the connection was closed
* @param {Boolean} [options.wasClean=false] Indicates whether or not the
* connection was cleanly closed
*/
constructor(type, options = {}) {
super(type);
this[kCode] = options.code === undefined ? 0 : options.code;
this[kReason] = options.reason === undefined ? '' : options.reason;
this[kWasClean] = options.wasClean === undefined ? false : options.wasClean;
}
/**
* @type {Number}
*/
get code() {
return this[kCode];
}
/**
* @type {String}
*/
get reason() {
return this[kReason];
}
/**
* @type {Boolean}
*/
get wasClean() {
return this[kWasClean];
}
}
Object.defineProperty(CloseEvent.prototype, 'code', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'reason', { enumerable: true });
Object.defineProperty(CloseEvent.prototype, 'wasClean', { enumerable: true });
/**
* Class representing an error event.
*
* @extends Event
*/
class ErrorEvent extends Event {
/**
* Create a new `ErrorEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.error=null] The error that generated this event
* @param {String} [options.message=''] The error message
*/
constructor(type, options = {}) {
super(type);
this[kError] = options.error === undefined ? null : options.error;
this[kMessage] = options.message === undefined ? '' : options.message;
}
/**
* @type {*}
*/
get error() {
return this[kError];
}
/**
* @type {String}
*/
get message() {
return this[kMessage];
}
}
Object.defineProperty(ErrorEvent.prototype, 'error', { enumerable: true });
Object.defineProperty(ErrorEvent.prototype, 'message', { enumerable: true });
/**
* Class representing a message event.
*
* @extends Event
*/
class MessageEvent extends Event {
/**
* Create a new `MessageEvent`.
*
* @param {String} type The name of the event
* @param {Object} [options] A dictionary object that allows for setting
* attributes via object members of the same name
* @param {*} [options.data=null] The message content
*/
constructor(type, options = {}) {
super(type);
this[kData] = options.data === undefined ? null : options.data;
}
/**
* @type {*}
*/
get data() {
return this[kData];
}
}
Object.defineProperty(MessageEvent.prototype, 'data', { enumerable: true });
/**
* This provides methods for emulating the `EventTarget` interface. It's not
* meant to be used directly.
*
* @mixin
*/
const EventTarget = {
/**
* Register an event listener.
*
* @param {String} type A string representing the event type to listen for
* @param {(Function|Object)} handler The listener to add
* @param {Object} [options] An options object specifies characteristics about
* the event listener
* @param {Boolean} [options.once=false] A `Boolean` indicating that the
* listener should be invoked at most once after being added. If `true`,
* the listener would be automatically removed when invoked.
* @public
*/
addEventListener(type, handler, options = {}) {
for (const listener of this.listeners(type)) {
if (
!options[kForOnEventAttribute] &&
listener[kListener] === handler &&
!listener[kForOnEventAttribute]
) {
return;
}
}
let wrapper;
if (type === 'message') {
wrapper = function onMessage(data, isBinary) {
const event = new MessageEvent('message', {
data: isBinary ? data : data.toString()
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'close') {
wrapper = function onClose(code, message) {
const event = new CloseEvent('close', {
code,
reason: message.toString(),
wasClean: this._closeFrameReceived && this._closeFrameSent
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'error') {
wrapper = function onError(error) {
const event = new ErrorEvent('error', {
error,
message: error.message
});
event[kTarget] = this;
callListener(handler, this, event);
};
} else if (type === 'open') {
wrapper = function onOpen() {
const event = new Event('open');
event[kTarget] = this;
callListener(handler, this, event);
};
} else {
return;
}
wrapper[kForOnEventAttribute] = !!options[kForOnEventAttribute];
wrapper[kListener] = handler;
if (options.once) {
this.once(type, wrapper);
} else {
this.on(type, wrapper);
}
},
/**
* Remove an event listener.
*
* @param {String} type A string representing the event type to remove
* @param {(Function|Object)} handler The listener to remove
* @public
*/
removeEventListener(type, handler) {
for (const listener of this.listeners(type)) {
if (listener[kListener] === handler && !listener[kForOnEventAttribute]) {
this.removeListener(type, listener);
break;
}
}
}
};
module.exports = {
CloseEvent,
ErrorEvent,
Event,
EventTarget,
MessageEvent
};
/**
* Call an event listener
*
* @param {(Function|Object)} listener The listener to call
* @param {*} thisArg The value to use as `this`` when calling the listener
* @param {Event} event The event to pass to the listener
* @private
*/
function callListener(listener, thisArg, event) {
if (typeof listener === 'object' && listener.handleEvent) {
listener.handleEvent.call(listener, event);
} else {
listener.call(thisArg, event);
}
}
+203
View File
@@ -0,0 +1,203 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Adds an offer to the map of extension offers or a parameter to the map of
* parameters.
*
* @param {Object} dest The map of extension offers or parameters
* @param {String} name The extension or parameter name
* @param {(Object|Boolean|String)} elem The extension parameters or the
* parameter value
* @private
*/
function push(dest, name, elem) {
if (dest[name] === undefined) dest[name] = [elem];
else dest[name].push(elem);
}
/**
* Parses the `Sec-WebSocket-Extensions` header into an object.
*
* @param {String} header The field value of the header
* @return {Object} The parsed object
* @public
*/
function parse(header) {
const offers = Object.create(null);
let params = Object.create(null);
let mustUnescape = false;
let isEscaping = false;
let inQuotes = false;
let extensionName;
let paramName;
let start = -1;
let code = -1;
let end = -1;
let i = 0;
for (; i < header.length; i++) {
code = header.charCodeAt(i);
if (extensionName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b /* ';' */ || code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const name = header.slice(start, end);
if (code === 0x2c) {
push(offers, name, params);
params = Object.create(null);
} else {
extensionName = name;
}
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (paramName === undefined) {
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x20 || code === 0x09) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
push(params, header.slice(start, end), true);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
start = end = -1;
} else if (code === 0x3d /* '=' */ && start !== -1 && end === -1) {
paramName = header.slice(start, i);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else {
//
// The value of a quoted-string after unescaping must conform to the
// token ABNF, so only token characters are valid.
// Ref: https://tools.ietf.org/html/rfc6455#section-9.1
//
if (isEscaping) {
if (tokenChars[code] !== 1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (start === -1) start = i;
else if (!mustUnescape) mustUnescape = true;
isEscaping = false;
} else if (inQuotes) {
if (tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (code === 0x22 /* '"' */ && start !== -1) {
inQuotes = false;
end = i;
} else if (code === 0x5c /* '\' */) {
isEscaping = true;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
} else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) {
inQuotes = true;
} else if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (start !== -1 && (code === 0x20 || code === 0x09)) {
if (end === -1) end = i;
} else if (code === 0x3b || code === 0x2c) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
let value = header.slice(start, end);
if (mustUnescape) {
value = value.replace(/\\/g, '');
mustUnescape = false;
}
push(params, paramName, value);
if (code === 0x2c) {
push(offers, extensionName, params);
params = Object.create(null);
extensionName = undefined;
}
paramName = undefined;
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
}
if (start === -1 || inQuotes || code === 0x20 || code === 0x09) {
throw new SyntaxError('Unexpected end of input');
}
if (end === -1) end = i;
const token = header.slice(start, end);
if (extensionName === undefined) {
push(offers, token, params);
} else {
if (paramName === undefined) {
push(params, token, true);
} else if (mustUnescape) {
push(params, paramName, token.replace(/\\/g, ''));
} else {
push(params, paramName, token);
}
push(offers, extensionName, params);
}
return offers;
}
/**
* Builds the `Sec-WebSocket-Extensions` header field value.
*
* @param {Object} extensions The map of extensions and parameters to format
* @return {String} A string representing the given object
* @public
*/
function format(extensions) {
return Object.keys(extensions)
.map((extension) => {
let configurations = extensions[extension];
if (!Array.isArray(configurations)) configurations = [configurations];
return configurations
.map((params) => {
return [extension]
.concat(
Object.keys(params).map((k) => {
let values = params[k];
if (!Array.isArray(values)) values = [values];
return values
.map((v) => (v === true ? k : `${k}=${v}`))
.join('; ');
})
)
.join('; ');
})
.join(', ');
})
.join(', ');
}
module.exports = { format, parse };
+55
View File
@@ -0,0 +1,55 @@
'use strict';
const kDone = Symbol('kDone');
const kRun = Symbol('kRun');
/**
* A very simple job queue with adjustable concurrency. Adapted from
* https://github.com/STRML/async-limiter
*/
class Limiter {
/**
* Creates a new `Limiter`.
*
* @param {Number} [concurrency=Infinity] The maximum number of jobs allowed
* to run concurrently
*/
constructor(concurrency) {
this[kDone] = () => {
this.pending--;
this[kRun]();
};
this.concurrency = concurrency || Infinity;
this.jobs = [];
this.pending = 0;
}
/**
* Adds a job to the queue.
*
* @param {Function} job The job to run
* @public
*/
add(job) {
this.jobs.push(job);
this[kRun]();
}
/**
* Removes a job from the queue and runs it if possible.
*
* @private
*/
[kRun]() {
if (this.pending === this.concurrency) return;
if (this.jobs.length) {
const job = this.jobs.shift();
this.pending++;
job(this[kDone]);
}
}
}
module.exports = Limiter;
+528
View File
@@ -0,0 +1,528 @@
'use strict';
const zlib = require('zlib');
const bufferUtil = require('./buffer-util');
const Limiter = require('./limiter');
const { kStatusCode } = require('./constants');
const FastBuffer = Buffer[Symbol.species];
const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
const kPerMessageDeflate = Symbol('permessage-deflate');
const kTotalLength = Symbol('total-length');
const kCallback = Symbol('callback');
const kBuffers = Symbol('buffers');
const kError = Symbol('error');
//
// We limit zlib concurrency, which prevents severe memory fragmentation
// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913
// and https://github.com/websockets/ws/issues/1202
//
// Intentionally global; it's the global thread pool that's an issue.
//
let zlibLimiter;
/**
* permessage-deflate implementation.
*/
class PerMessageDeflate {
/**
* Creates a PerMessageDeflate instance.
*
* @param {Object} [options] Configuration options
* @param {(Boolean|Number)} [options.clientMaxWindowBits] Advertise support
* for, or request, a custom client window size
* @param {Boolean} [options.clientNoContextTakeover=false] Advertise/
* acknowledge disabling of client context takeover
* @param {Number} [options.concurrencyLimit=10] The number of concurrent
* calls to zlib
* @param {Boolean} [options.isServer=false] Create the instance in either
* server or client mode
* @param {Number} [options.maxPayload=0] The maximum allowed message length
* @param {(Boolean|Number)} [options.serverMaxWindowBits] Request/confirm the
* use of a custom server window size
* @param {Boolean} [options.serverNoContextTakeover=false] Request/accept
* disabling of server context takeover
* @param {Number} [options.threshold=1024] Size (in bytes) below which
* messages should not be compressed if context takeover is disabled
* @param {Object} [options.zlibDeflateOptions] Options to pass to zlib on
* deflate
* @param {Object} [options.zlibInflateOptions] Options to pass to zlib on
* inflate
*/
constructor(options) {
this._options = options || {};
this._threshold =
this._options.threshold !== undefined ? this._options.threshold : 1024;
this._maxPayload = this._options.maxPayload | 0;
this._isServer = !!this._options.isServer;
this._deflate = null;
this._inflate = null;
this.params = null;
if (!zlibLimiter) {
const concurrency =
this._options.concurrencyLimit !== undefined
? this._options.concurrencyLimit
: 10;
zlibLimiter = new Limiter(concurrency);
}
}
/**
* @type {String}
*/
static get extensionName() {
return 'permessage-deflate';
}
/**
* Create an extension negotiation offer.
*
* @return {Object} Extension parameters
* @public
*/
offer() {
const params = {};
if (this._options.serverNoContextTakeover) {
params.server_no_context_takeover = true;
}
if (this._options.clientNoContextTakeover) {
params.client_no_context_takeover = true;
}
if (this._options.serverMaxWindowBits) {
params.server_max_window_bits = this._options.serverMaxWindowBits;
}
if (this._options.clientMaxWindowBits) {
params.client_max_window_bits = this._options.clientMaxWindowBits;
} else if (this._options.clientMaxWindowBits == null) {
params.client_max_window_bits = true;
}
return params;
}
/**
* Accept an extension negotiation offer/response.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Object} Accepted configuration
* @public
*/
accept(configurations) {
configurations = this.normalizeParams(configurations);
this.params = this._isServer
? this.acceptAsServer(configurations)
: this.acceptAsClient(configurations);
return this.params;
}
/**
* Releases all resources used by the extension.
*
* @public
*/
cleanup() {
if (this._inflate) {
this._inflate.close();
this._inflate = null;
}
if (this._deflate) {
const callback = this._deflate[kCallback];
this._deflate.close();
this._deflate = null;
if (callback) {
callback(
new Error(
'The deflate stream was closed while data was being processed'
)
);
}
}
}
/**
* Accept an extension negotiation offer.
*
* @param {Array} offers The extension negotiation offers
* @return {Object} Accepted configuration
* @private
*/
acceptAsServer(offers) {
const opts = this._options;
const accepted = offers.find((params) => {
if (
(opts.serverNoContextTakeover === false &&
params.server_no_context_takeover) ||
(params.server_max_window_bits &&
(opts.serverMaxWindowBits === false ||
(typeof opts.serverMaxWindowBits === 'number' &&
opts.serverMaxWindowBits > params.server_max_window_bits))) ||
(typeof opts.clientMaxWindowBits === 'number' &&
!params.client_max_window_bits)
) {
return false;
}
return true;
});
if (!accepted) {
throw new Error('None of the extension offers can be accepted');
}
if (opts.serverNoContextTakeover) {
accepted.server_no_context_takeover = true;
}
if (opts.clientNoContextTakeover) {
accepted.client_no_context_takeover = true;
}
if (typeof opts.serverMaxWindowBits === 'number') {
accepted.server_max_window_bits = opts.serverMaxWindowBits;
}
if (typeof opts.clientMaxWindowBits === 'number') {
accepted.client_max_window_bits = opts.clientMaxWindowBits;
} else if (
accepted.client_max_window_bits === true ||
opts.clientMaxWindowBits === false
) {
delete accepted.client_max_window_bits;
}
return accepted;
}
/**
* Accept the extension negotiation response.
*
* @param {Array} response The extension negotiation response
* @return {Object} Accepted configuration
* @private
*/
acceptAsClient(response) {
const params = response[0];
if (
this._options.clientNoContextTakeover === false &&
params.client_no_context_takeover
) {
throw new Error('Unexpected parameter "client_no_context_takeover"');
}
if (!params.client_max_window_bits) {
if (typeof this._options.clientMaxWindowBits === 'number') {
params.client_max_window_bits = this._options.clientMaxWindowBits;
}
} else if (
this._options.clientMaxWindowBits === false ||
(typeof this._options.clientMaxWindowBits === 'number' &&
params.client_max_window_bits > this._options.clientMaxWindowBits)
) {
throw new Error(
'Unexpected or invalid parameter "client_max_window_bits"'
);
}
return params;
}
/**
* Normalize parameters.
*
* @param {Array} configurations The extension negotiation offers/reponse
* @return {Array} The offers/response with normalized parameters
* @private
*/
normalizeParams(configurations) {
configurations.forEach((params) => {
Object.keys(params).forEach((key) => {
let value = params[key];
if (value.length > 1) {
throw new Error(`Parameter "${key}" must have only a single value`);
}
value = value[0];
if (key === 'client_max_window_bits') {
if (value !== true) {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (!this._isServer) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else if (key === 'server_max_window_bits') {
const num = +value;
if (!Number.isInteger(num) || num < 8 || num > 15) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
value = num;
} else if (
key === 'client_no_context_takeover' ||
key === 'server_no_context_takeover'
) {
if (value !== true) {
throw new TypeError(
`Invalid value for parameter "${key}": ${value}`
);
}
} else {
throw new Error(`Unknown parameter "${key}"`);
}
params[key] = value;
});
});
return configurations;
}
/**
* Decompress data. Concurrency limited.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
decompress(data, fin, callback) {
zlibLimiter.add((done) => {
this._decompress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Compress data. Concurrency limited.
*
* @param {(Buffer|String)} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @public
*/
compress(data, fin, callback) {
zlibLimiter.add((done) => {
this._compress(data, fin, (err, result) => {
done();
callback(err, result);
});
});
}
/**
* Decompress data.
*
* @param {Buffer} data Compressed data
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_decompress(data, fin, callback) {
const endpoint = this._isServer ? 'client' : 'server';
if (!this._inflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._inflate = zlib.createInflateRaw({
...this._options.zlibInflateOptions,
windowBits
});
this._inflate[kPerMessageDeflate] = this;
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
this._inflate.on('error', inflateOnError);
this._inflate.on('data', inflateOnData);
}
this._inflate[kCallback] = callback;
this._inflate.write(data);
if (fin) this._inflate.write(TRAILER);
this._inflate.flush(() => {
const err = this._inflate[kError];
if (err) {
this._inflate.close();
this._inflate = null;
callback(err);
return;
}
const data = bufferUtil.concat(
this._inflate[kBuffers],
this._inflate[kTotalLength]
);
if (this._inflate._readableState.endEmitted) {
this._inflate.close();
this._inflate = null;
} else {
this._inflate[kTotalLength] = 0;
this._inflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._inflate.reset();
}
}
callback(null, data);
});
}
/**
* Compress data.
*
* @param {(Buffer|String)} data Data to compress
* @param {Boolean} fin Specifies whether or not this is the last fragment
* @param {Function} callback Callback
* @private
*/
_compress(data, fin, callback) {
const endpoint = this._isServer ? 'server' : 'client';
if (!this._deflate) {
const key = `${endpoint}_max_window_bits`;
const windowBits =
typeof this.params[key] !== 'number'
? zlib.Z_DEFAULT_WINDOWBITS
: this.params[key];
this._deflate = zlib.createDeflateRaw({
...this._options.zlibDeflateOptions,
windowBits
});
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
this._deflate.on('data', deflateOnData);
}
this._deflate[kCallback] = callback;
this._deflate.write(data);
this._deflate.flush(zlib.Z_SYNC_FLUSH, () => {
if (!this._deflate) {
//
// The deflate stream was closed while data was being processed.
//
return;
}
let data = bufferUtil.concat(
this._deflate[kBuffers],
this._deflate[kTotalLength]
);
if (fin) {
data = new FastBuffer(data.buffer, data.byteOffset, data.length - 4);
}
//
// Ensure that the callback will not be called again in
// `PerMessageDeflate#cleanup()`.
//
this._deflate[kCallback] = null;
this._deflate[kTotalLength] = 0;
this._deflate[kBuffers] = [];
if (fin && this.params[`${endpoint}_no_context_takeover`]) {
this._deflate.reset();
}
callback(null, data);
});
}
}
module.exports = PerMessageDeflate;
/**
* The listener of the `zlib.DeflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function deflateOnData(chunk) {
this[kBuffers].push(chunk);
this[kTotalLength] += chunk.length;
}
/**
* The listener of the `zlib.InflateRaw` stream `'data'` event.
*
* @param {Buffer} chunk A chunk of data
* @private
*/
function inflateOnData(chunk) {
this[kTotalLength] += chunk.length;
if (
this[kPerMessageDeflate]._maxPayload < 1 ||
this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload
) {
this[kBuffers].push(chunk);
return;
}
this[kError] = new RangeError('Max payload size exceeded');
this[kError].code = 'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH';
this[kError][kStatusCode] = 1009;
this.removeListener('data', inflateOnData);
//
// The choice to employ `zlib.reset()` over `zlib.close()` is dictated by the
// fact that in Node.js versions prior to 13.10.0, the callback for
// `zlib.flush()` is not called if `zlib.close()` is used. Utilizing
// `zlib.reset()` ensures that either the callback is invoked or an error is
// emitted.
//
this.reset();
}
/**
* The listener of the `zlib.InflateRaw` stream `'error'` event.
*
* @param {Error} err The emitted error
* @private
*/
function inflateOnError(err) {
//
// There is no need to call `Zlib#close()` as the handle is automatically
// closed when an error is emitted.
//
this[kPerMessageDeflate]._inflate = null;
if (this[kError]) {
this[kCallback](this[kError]);
return;
}
err[kStatusCode] = 1007;
this[kCallback](err);
}
+706
View File
@@ -0,0 +1,706 @@
'use strict';
const { Writable } = require('stream');
const PerMessageDeflate = require('./permessage-deflate');
const {
BINARY_TYPES,
EMPTY_BUFFER,
kStatusCode,
kWebSocket
} = require('./constants');
const { concat, toArrayBuffer, unmask } = require('./buffer-util');
const { isValidStatusCode, isValidUTF8 } = require('./validation');
const FastBuffer = Buffer[Symbol.species];
const GET_INFO = 0;
const GET_PAYLOAD_LENGTH_16 = 1;
const GET_PAYLOAD_LENGTH_64 = 2;
const GET_MASK = 3;
const GET_DATA = 4;
const INFLATING = 5;
const DEFER_EVENT = 6;
/**
* HyBi Receiver implementation.
*
* @extends Writable
*/
class Receiver extends Writable {
/**
* Creates a Receiver instance.
*
* @param {Object} [options] Options object
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
* multiple times in the same tick
* @param {String} [options.binaryType=nodebuffer] The type for binary data
* @param {Object} [options.extensions] An object containing the negotiated
* extensions
* @param {Boolean} [options.isServer=false] Specifies whether to operate in
* client or server mode
* @param {Number} [options.maxPayload=0] The maximum allowed message length
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
*/
constructor(options = {}) {
super();
this._allowSynchronousEvents =
options.allowSynchronousEvents !== undefined
? options.allowSynchronousEvents
: true;
this._binaryType = options.binaryType || BINARY_TYPES[0];
this._extensions = options.extensions || {};
this._isServer = !!options.isServer;
this._maxPayload = options.maxPayload | 0;
this._skipUTF8Validation = !!options.skipUTF8Validation;
this[kWebSocket] = undefined;
this._bufferedBytes = 0;
this._buffers = [];
this._compressed = false;
this._payloadLength = 0;
this._mask = undefined;
this._fragmented = 0;
this._masked = false;
this._fin = false;
this._opcode = 0;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragments = [];
this._errored = false;
this._loop = false;
this._state = GET_INFO;
}
/**
* Implements `Writable.prototype._write()`.
*
* @param {Buffer} chunk The chunk of data to write
* @param {String} encoding The character encoding of `chunk`
* @param {Function} cb Callback
* @private
*/
_write(chunk, encoding, cb) {
if (this._opcode === 0x08 && this._state == GET_INFO) return cb();
this._bufferedBytes += chunk.length;
this._buffers.push(chunk);
this.startLoop(cb);
}
/**
* Consumes `n` bytes from the buffered data.
*
* @param {Number} n The number of bytes to consume
* @return {Buffer} The consumed bytes
* @private
*/
consume(n) {
this._bufferedBytes -= n;
if (n === this._buffers[0].length) return this._buffers.shift();
if (n < this._buffers[0].length) {
const buf = this._buffers[0];
this._buffers[0] = new FastBuffer(
buf.buffer,
buf.byteOffset + n,
buf.length - n
);
return new FastBuffer(buf.buffer, buf.byteOffset, n);
}
const dst = Buffer.allocUnsafe(n);
do {
const buf = this._buffers[0];
const offset = dst.length - n;
if (n >= buf.length) {
dst.set(this._buffers.shift(), offset);
} else {
dst.set(new Uint8Array(buf.buffer, buf.byteOffset, n), offset);
this._buffers[0] = new FastBuffer(
buf.buffer,
buf.byteOffset + n,
buf.length - n
);
}
n -= buf.length;
} while (n > 0);
return dst;
}
/**
* Starts the parsing loop.
*
* @param {Function} cb Callback
* @private
*/
startLoop(cb) {
this._loop = true;
do {
switch (this._state) {
case GET_INFO:
this.getInfo(cb);
break;
case GET_PAYLOAD_LENGTH_16:
this.getPayloadLength16(cb);
break;
case GET_PAYLOAD_LENGTH_64:
this.getPayloadLength64(cb);
break;
case GET_MASK:
this.getMask();
break;
case GET_DATA:
this.getData(cb);
break;
case INFLATING:
case DEFER_EVENT:
this._loop = false;
return;
}
} while (this._loop);
if (!this._errored) cb();
}
/**
* Reads the first two bytes of a frame.
*
* @param {Function} cb Callback
* @private
*/
getInfo(cb) {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
const buf = this.consume(2);
if ((buf[0] & 0x30) !== 0x00) {
const error = this.createError(
RangeError,
'RSV2 and RSV3 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_2_3'
);
cb(error);
return;
}
const compressed = (buf[0] & 0x40) === 0x40;
if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
this._fin = (buf[0] & 0x80) === 0x80;
this._opcode = buf[0] & 0x0f;
this._payloadLength = buf[1] & 0x7f;
if (this._opcode === 0x00) {
if (compressed) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
if (!this._fragmented) {
const error = this.createError(
RangeError,
'invalid opcode 0',
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
this._opcode = this._fragmented;
} else if (this._opcode === 0x01 || this._opcode === 0x02) {
if (this._fragmented) {
const error = this.createError(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
this._compressed = compressed;
} else if (this._opcode > 0x07 && this._opcode < 0x0b) {
if (!this._fin) {
const error = this.createError(
RangeError,
'FIN must be set',
true,
1002,
'WS_ERR_EXPECTED_FIN'
);
cb(error);
return;
}
if (compressed) {
const error = this.createError(
RangeError,
'RSV1 must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_RSV_1'
);
cb(error);
return;
}
if (
this._payloadLength > 0x7d ||
(this._opcode === 0x08 && this._payloadLength === 1)
) {
const error = this.createError(
RangeError,
`invalid payload length ${this._payloadLength}`,
true,
1002,
'WS_ERR_INVALID_CONTROL_PAYLOAD_LENGTH'
);
cb(error);
return;
}
} else {
const error = this.createError(
RangeError,
`invalid opcode ${this._opcode}`,
true,
1002,
'WS_ERR_INVALID_OPCODE'
);
cb(error);
return;
}
if (!this._fin && !this._fragmented) this._fragmented = this._opcode;
this._masked = (buf[1] & 0x80) === 0x80;
if (this._isServer) {
if (!this._masked) {
const error = this.createError(
RangeError,
'MASK must be set',
true,
1002,
'WS_ERR_EXPECTED_MASK'
);
cb(error);
return;
}
} else if (this._masked) {
const error = this.createError(
RangeError,
'MASK must be clear',
true,
1002,
'WS_ERR_UNEXPECTED_MASK'
);
cb(error);
return;
}
if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16;
else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64;
else this.haveLength(cb);
}
/**
* Gets extended payload length (7+16).
*
* @param {Function} cb Callback
* @private
*/
getPayloadLength16(cb) {
if (this._bufferedBytes < 2) {
this._loop = false;
return;
}
this._payloadLength = this.consume(2).readUInt16BE(0);
this.haveLength(cb);
}
/**
* Gets extended payload length (7+64).
*
* @param {Function} cb Callback
* @private
*/
getPayloadLength64(cb) {
if (this._bufferedBytes < 8) {
this._loop = false;
return;
}
const buf = this.consume(8);
const num = buf.readUInt32BE(0);
//
// The maximum safe integer in JavaScript is 2^53 - 1. An error is returned
// if payload length is greater than this number.
//
if (num > Math.pow(2, 53 - 32) - 1) {
const error = this.createError(
RangeError,
'Unsupported WebSocket frame: payload length > 2^53 - 1',
false,
1009,
'WS_ERR_UNSUPPORTED_DATA_PAYLOAD_LENGTH'
);
cb(error);
return;
}
this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
this.haveLength(cb);
}
/**
* Payload length has been read.
*
* @param {Function} cb Callback
* @private
*/
haveLength(cb) {
if (this._payloadLength && this._opcode < 0x08) {
this._totalPayloadLength += this._payloadLength;
if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) {
const error = this.createError(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
);
cb(error);
return;
}
}
if (this._masked) this._state = GET_MASK;
else this._state = GET_DATA;
}
/**
* Reads mask bytes.
*
* @private
*/
getMask() {
if (this._bufferedBytes < 4) {
this._loop = false;
return;
}
this._mask = this.consume(4);
this._state = GET_DATA;
}
/**
* Reads data bytes.
*
* @param {Function} cb Callback
* @private
*/
getData(cb) {
let data = EMPTY_BUFFER;
if (this._payloadLength) {
if (this._bufferedBytes < this._payloadLength) {
this._loop = false;
return;
}
data = this.consume(this._payloadLength);
if (
this._masked &&
(this._mask[0] | this._mask[1] | this._mask[2] | this._mask[3]) !== 0
) {
unmask(data, this._mask);
}
}
if (this._opcode > 0x07) {
this.controlMessage(data, cb);
return;
}
if (this._compressed) {
this._state = INFLATING;
this.decompress(data, cb);
return;
}
if (data.length) {
//
// This message is not compressed so its length is the sum of the payload
// length of all fragments.
//
this._messageLength = this._totalPayloadLength;
this._fragments.push(data);
}
this.dataMessage(cb);
}
/**
* Decompresses data.
*
* @param {Buffer} data Compressed data
* @param {Function} cb Callback
* @private
*/
decompress(data, cb) {
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
perMessageDeflate.decompress(data, this._fin, (err, buf) => {
if (err) return cb(err);
if (buf.length) {
this._messageLength += buf.length;
if (this._messageLength > this._maxPayload && this._maxPayload > 0) {
const error = this.createError(
RangeError,
'Max payload size exceeded',
false,
1009,
'WS_ERR_UNSUPPORTED_MESSAGE_LENGTH'
);
cb(error);
return;
}
this._fragments.push(buf);
}
this.dataMessage(cb);
if (this._state === GET_INFO) this.startLoop(cb);
});
}
/**
* Handles a data message.
*
* @param {Function} cb Callback
* @private
*/
dataMessage(cb) {
if (!this._fin) {
this._state = GET_INFO;
return;
}
const messageLength = this._messageLength;
const fragments = this._fragments;
this._totalPayloadLength = 0;
this._messageLength = 0;
this._fragmented = 0;
this._fragments = [];
if (this._opcode === 2) {
let data;
if (this._binaryType === 'nodebuffer') {
data = concat(fragments, messageLength);
} else if (this._binaryType === 'arraybuffer') {
data = toArrayBuffer(concat(fragments, messageLength));
} else if (this._binaryType === 'blob') {
data = new Blob(fragments);
} else {
data = fragments;
}
if (this._allowSynchronousEvents) {
this.emit('message', data, true);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit('message', data, true);
this._state = GET_INFO;
this.startLoop(cb);
});
}
} else {
const buf = concat(fragments, messageLength);
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
const error = this.createError(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
cb(error);
return;
}
if (this._state === INFLATING || this._allowSynchronousEvents) {
this.emit('message', buf, false);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit('message', buf, false);
this._state = GET_INFO;
this.startLoop(cb);
});
}
}
}
/**
* Handles a control message.
*
* @param {Buffer} data Data to handle
* @return {(Error|RangeError|undefined)} A possible error
* @private
*/
controlMessage(data, cb) {
if (this._opcode === 0x08) {
if (data.length === 0) {
this._loop = false;
this.emit('conclude', 1005, EMPTY_BUFFER);
this.end();
} else {
const code = data.readUInt16BE(0);
if (!isValidStatusCode(code)) {
const error = this.createError(
RangeError,
`invalid status code ${code}`,
true,
1002,
'WS_ERR_INVALID_CLOSE_CODE'
);
cb(error);
return;
}
const buf = new FastBuffer(
data.buffer,
data.byteOffset + 2,
data.length - 2
);
if (!this._skipUTF8Validation && !isValidUTF8(buf)) {
const error = this.createError(
Error,
'invalid UTF-8 sequence',
true,
1007,
'WS_ERR_INVALID_UTF8'
);
cb(error);
return;
}
this._loop = false;
this.emit('conclude', code, buf);
this.end();
}
this._state = GET_INFO;
return;
}
if (this._allowSynchronousEvents) {
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
this._state = GET_INFO;
} else {
this._state = DEFER_EVENT;
setImmediate(() => {
this.emit(this._opcode === 0x09 ? 'ping' : 'pong', data);
this._state = GET_INFO;
this.startLoop(cb);
});
}
}
/**
* Builds an error object.
*
* @param {function(new:Error|RangeError)} ErrorCtor The error constructor
* @param {String} message The error message
* @param {Boolean} prefix Specifies whether or not to add a default prefix to
* `message`
* @param {Number} statusCode The status code
* @param {String} errorCode The exposed error code
* @return {(Error|RangeError)} The error
* @private
*/
createError(ErrorCtor, message, prefix, statusCode, errorCode) {
this._loop = false;
this._errored = true;
const err = new ErrorCtor(
prefix ? `Invalid WebSocket frame: ${message}` : message
);
Error.captureStackTrace(err, this.createError);
err.code = errorCode;
err[kStatusCode] = statusCode;
return err;
}
}
module.exports = Receiver;
+602
View File
@@ -0,0 +1,602 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex" }] */
'use strict';
const { Duplex } = require('stream');
const { randomFillSync } = require('crypto');
const PerMessageDeflate = require('./permessage-deflate');
const { EMPTY_BUFFER, kWebSocket, NOOP } = require('./constants');
const { isBlob, isValidStatusCode } = require('./validation');
const { mask: applyMask, toBuffer } = require('./buffer-util');
const kByteLength = Symbol('kByteLength');
const maskBuffer = Buffer.alloc(4);
const RANDOM_POOL_SIZE = 8 * 1024;
let randomPool;
let randomPoolPointer = RANDOM_POOL_SIZE;
const DEFAULT = 0;
const DEFLATING = 1;
const GET_BLOB_DATA = 2;
/**
* HyBi Sender implementation.
*/
class Sender {
/**
* Creates a Sender instance.
*
* @param {Duplex} socket The connection socket
* @param {Object} [extensions] An object containing the negotiated extensions
* @param {Function} [generateMask] The function used to generate the masking
* key
*/
constructor(socket, extensions, generateMask) {
this._extensions = extensions || {};
if (generateMask) {
this._generateMask = generateMask;
this._maskBuffer = Buffer.alloc(4);
}
this._socket = socket;
this._firstFragment = true;
this._compress = false;
this._bufferedBytes = 0;
this._queue = [];
this._state = DEFAULT;
this.onerror = NOOP;
this[kWebSocket] = undefined;
}
/**
* Frames a piece of data according to the HyBi WebSocket protocol.
*
* @param {(Buffer|String)} data The data to frame
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @return {(Buffer|String)[]} The framed data
* @public
*/
static frame(data, options) {
let mask;
let merge = false;
let offset = 2;
let skipMasking = false;
if (options.mask) {
mask = options.maskBuffer || maskBuffer;
if (options.generateMask) {
options.generateMask(mask);
} else {
if (randomPoolPointer === RANDOM_POOL_SIZE) {
/* istanbul ignore else */
if (randomPool === undefined) {
//
// This is lazily initialized because server-sent frames must not
// be masked so it may never be used.
//
randomPool = Buffer.alloc(RANDOM_POOL_SIZE);
}
randomFillSync(randomPool, 0, RANDOM_POOL_SIZE);
randomPoolPointer = 0;
}
mask[0] = randomPool[randomPoolPointer++];
mask[1] = randomPool[randomPoolPointer++];
mask[2] = randomPool[randomPoolPointer++];
mask[3] = randomPool[randomPoolPointer++];
}
skipMasking = (mask[0] | mask[1] | mask[2] | mask[3]) === 0;
offset = 6;
}
let dataLength;
if (typeof data === 'string') {
if (
(!options.mask || skipMasking) &&
options[kByteLength] !== undefined
) {
dataLength = options[kByteLength];
} else {
data = Buffer.from(data);
dataLength = data.length;
}
} else {
dataLength = data.length;
merge = options.mask && options.readOnly && !skipMasking;
}
let payloadLength = dataLength;
if (dataLength >= 65536) {
offset += 8;
payloadLength = 127;
} else if (dataLength > 125) {
offset += 2;
payloadLength = 126;
}
const target = Buffer.allocUnsafe(merge ? dataLength + offset : offset);
target[0] = options.fin ? options.opcode | 0x80 : options.opcode;
if (options.rsv1) target[0] |= 0x40;
target[1] = payloadLength;
if (payloadLength === 126) {
target.writeUInt16BE(dataLength, 2);
} else if (payloadLength === 127) {
target[2] = target[3] = 0;
target.writeUIntBE(dataLength, 4, 6);
}
if (!options.mask) return [target, data];
target[1] |= 0x80;
target[offset - 4] = mask[0];
target[offset - 3] = mask[1];
target[offset - 2] = mask[2];
target[offset - 1] = mask[3];
if (skipMasking) return [target, data];
if (merge) {
applyMask(data, mask, target, offset, dataLength);
return [target];
}
applyMask(data, mask, data, 0, dataLength);
return [target, data];
}
/**
* Sends a close message to the other peer.
*
* @param {Number} [code] The status code component of the body
* @param {(String|Buffer)} [data] The message component of the body
* @param {Boolean} [mask=false] Specifies whether or not to mask the message
* @param {Function} [cb] Callback
* @public
*/
close(code, data, mask, cb) {
let buf;
if (code === undefined) {
buf = EMPTY_BUFFER;
} else if (typeof code !== 'number' || !isValidStatusCode(code)) {
throw new TypeError('First argument must be a valid error code number');
} else if (data === undefined || !data.length) {
buf = Buffer.allocUnsafe(2);
buf.writeUInt16BE(code, 0);
} else {
const length = Buffer.byteLength(data);
if (length > 123) {
throw new RangeError('The message must not be greater than 123 bytes');
}
buf = Buffer.allocUnsafe(2 + length);
buf.writeUInt16BE(code, 0);
if (typeof data === 'string') {
buf.write(data, 2);
} else {
buf.set(data, 2);
}
}
const options = {
[kByteLength]: buf.length,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x08,
readOnly: false,
rsv1: false
};
if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, buf, false, options, cb]);
} else {
this.sendFrame(Sender.frame(buf, options), cb);
}
}
/**
* Sends a ping message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
ping(data, mask, cb) {
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (byteLength > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
const options = {
[kByteLength]: byteLength,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x09,
readOnly,
rsv1: false
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, false, options, cb]);
} else {
this.getBlobData(data, false, options, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, false, options, cb]);
} else {
this.sendFrame(Sender.frame(data, options), cb);
}
}
/**
* Sends a pong message to the other peer.
*
* @param {*} data The message to send
* @param {Boolean} [mask=false] Specifies whether or not to mask `data`
* @param {Function} [cb] Callback
* @public
*/
pong(data, mask, cb) {
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (byteLength > 125) {
throw new RangeError('The data size must not be greater than 125 bytes');
}
const options = {
[kByteLength]: byteLength,
fin: true,
generateMask: this._generateMask,
mask,
maskBuffer: this._maskBuffer,
opcode: 0x0a,
readOnly,
rsv1: false
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, false, options, cb]);
} else {
this.getBlobData(data, false, options, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, false, options, cb]);
} else {
this.sendFrame(Sender.frame(data, options), cb);
}
}
/**
* Sends a data message to the other peer.
*
* @param {*} data The message to send
* @param {Object} options Options object
* @param {Boolean} [options.binary=false] Specifies whether `data` is binary
* or text
* @param {Boolean} [options.compress=false] Specifies whether or not to
* compress `data`
* @param {Boolean} [options.fin=false] Specifies whether the fragment is the
* last one
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Function} [cb] Callback
* @public
*/
send(data, options, cb) {
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
let opcode = options.binary ? 2 : 1;
let rsv1 = options.compress;
let byteLength;
let readOnly;
if (typeof data === 'string') {
byteLength = Buffer.byteLength(data);
readOnly = false;
} else if (isBlob(data)) {
byteLength = data.size;
readOnly = false;
} else {
data = toBuffer(data);
byteLength = data.length;
readOnly = toBuffer.readOnly;
}
if (this._firstFragment) {
this._firstFragment = false;
if (
rsv1 &&
perMessageDeflate &&
perMessageDeflate.params[
perMessageDeflate._isServer
? 'server_no_context_takeover'
: 'client_no_context_takeover'
]
) {
rsv1 = byteLength >= perMessageDeflate._threshold;
}
this._compress = rsv1;
} else {
rsv1 = false;
opcode = 0;
}
if (options.fin) this._firstFragment = true;
const opts = {
[kByteLength]: byteLength,
fin: options.fin,
generateMask: this._generateMask,
mask: options.mask,
maskBuffer: this._maskBuffer,
opcode,
readOnly,
rsv1
};
if (isBlob(data)) {
if (this._state !== DEFAULT) {
this.enqueue([this.getBlobData, data, this._compress, opts, cb]);
} else {
this.getBlobData(data, this._compress, opts, cb);
}
} else if (this._state !== DEFAULT) {
this.enqueue([this.dispatch, data, this._compress, opts, cb]);
} else {
this.dispatch(data, this._compress, opts, cb);
}
}
/**
* Gets the contents of a blob as binary data.
*
* @param {Blob} blob The blob
* @param {Boolean} [compress=false] Specifies whether or not to compress
* the data
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @param {Function} [cb] Callback
* @private
*/
getBlobData(blob, compress, options, cb) {
this._bufferedBytes += options[kByteLength];
this._state = GET_BLOB_DATA;
blob
.arrayBuffer()
.then((arrayBuffer) => {
if (this._socket.destroyed) {
const err = new Error(
'The socket was closed while the blob was being read'
);
//
// `callCallbacks` is called in the next tick to ensure that errors
// that might be thrown in the callbacks behave like errors thrown
// outside the promise chain.
//
process.nextTick(callCallbacks, this, err, cb);
return;
}
this._bufferedBytes -= options[kByteLength];
const data = toBuffer(arrayBuffer);
if (!compress) {
this._state = DEFAULT;
this.sendFrame(Sender.frame(data, options), cb);
this.dequeue();
} else {
this.dispatch(data, compress, options, cb);
}
})
.catch((err) => {
//
// `onError` is called in the next tick for the same reason that
// `callCallbacks` above is.
//
process.nextTick(onError, this, err, cb);
});
}
/**
* Dispatches a message.
*
* @param {(Buffer|String)} data The message to send
* @param {Boolean} [compress=false] Specifies whether or not to compress
* `data`
* @param {Object} options Options object
* @param {Boolean} [options.fin=false] Specifies whether or not to set the
* FIN bit
* @param {Function} [options.generateMask] The function used to generate the
* masking key
* @param {Boolean} [options.mask=false] Specifies whether or not to mask
* `data`
* @param {Buffer} [options.maskBuffer] The buffer used to store the masking
* key
* @param {Number} options.opcode The opcode
* @param {Boolean} [options.readOnly=false] Specifies whether `data` can be
* modified
* @param {Boolean} [options.rsv1=false] Specifies whether or not to set the
* RSV1 bit
* @param {Function} [cb] Callback
* @private
*/
dispatch(data, compress, options, cb) {
if (!compress) {
this.sendFrame(Sender.frame(data, options), cb);
return;
}
const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
this._bufferedBytes += options[kByteLength];
this._state = DEFLATING;
perMessageDeflate.compress(data, options.fin, (_, buf) => {
if (this._socket.destroyed) {
const err = new Error(
'The socket was closed while data was being compressed'
);
callCallbacks(this, err, cb);
return;
}
this._bufferedBytes -= options[kByteLength];
this._state = DEFAULT;
options.readOnly = false;
this.sendFrame(Sender.frame(buf, options), cb);
this.dequeue();
});
}
/**
* Executes queued send operations.
*
* @private
*/
dequeue() {
while (this._state === DEFAULT && this._queue.length) {
const params = this._queue.shift();
this._bufferedBytes -= params[3][kByteLength];
Reflect.apply(params[0], this, params.slice(1));
}
}
/**
* Enqueues a send operation.
*
* @param {Array} params Send operation parameters.
* @private
*/
enqueue(params) {
this._bufferedBytes += params[3][kByteLength];
this._queue.push(params);
}
/**
* Sends a frame.
*
* @param {(Buffer | String)[]} list The frame to send
* @param {Function} [cb] Callback
* @private
*/
sendFrame(list, cb) {
if (list.length === 2) {
this._socket.cork();
this._socket.write(list[0]);
this._socket.write(list[1], cb);
this._socket.uncork();
} else {
this._socket.write(list[0], cb);
}
}
}
module.exports = Sender;
/**
* Calls queued callbacks with an error.
*
* @param {Sender} sender The `Sender` instance
* @param {Error} err The error to call the callbacks with
* @param {Function} [cb] The first callback
* @private
*/
function callCallbacks(sender, err, cb) {
if (typeof cb === 'function') cb(err);
for (let i = 0; i < sender._queue.length; i++) {
const params = sender._queue[i];
const callback = params[params.length - 1];
if (typeof callback === 'function') callback(err);
}
}
/**
* Handles a `Sender` error.
*
* @param {Sender} sender The `Sender` instance
* @param {Error} err The error
* @param {Function} [cb] The first pending callback
* @private
*/
function onError(sender, err, cb) {
callCallbacks(sender, err, cb);
sender.onerror(err);
}
+161
View File
@@ -0,0 +1,161 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^WebSocket$" }] */
'use strict';
const WebSocket = require('./websocket');
const { Duplex } = require('stream');
/**
* Emits the `'close'` event on a stream.
*
* @param {Duplex} stream The stream.
* @private
*/
function emitClose(stream) {
stream.emit('close');
}
/**
* The listener of the `'end'` event.
*
* @private
*/
function duplexOnEnd() {
if (!this.destroyed && this._writableState.finished) {
this.destroy();
}
}
/**
* The listener of the `'error'` event.
*
* @param {Error} err The error
* @private
*/
function duplexOnError(err) {
this.removeListener('error', duplexOnError);
this.destroy();
if (this.listenerCount('error') === 0) {
// Do not suppress the throwing behavior.
this.emit('error', err);
}
}
/**
* Wraps a `WebSocket` in a duplex stream.
*
* @param {WebSocket} ws The `WebSocket` to wrap
* @param {Object} [options] The options for the `Duplex` constructor
* @return {Duplex} The duplex stream
* @public
*/
function createWebSocketStream(ws, options) {
let terminateOnDestroy = true;
const duplex = new Duplex({
...options,
autoDestroy: false,
emitClose: false,
objectMode: false,
writableObjectMode: false
});
ws.on('message', function message(msg, isBinary) {
const data =
!isBinary && duplex._readableState.objectMode ? msg.toString() : msg;
if (!duplex.push(data)) ws.pause();
});
ws.once('error', function error(err) {
if (duplex.destroyed) return;
// Prevent `ws.terminate()` from being called by `duplex._destroy()`.
//
// - If the `'error'` event is emitted before the `'open'` event, then
// `ws.terminate()` is a noop as no socket is assigned.
// - Otherwise, the error is re-emitted by the listener of the `'error'`
// event of the `Receiver` object. The listener already closes the
// connection by calling `ws.close()`. This allows a close frame to be
// sent to the other peer. If `ws.terminate()` is called right after this,
// then the close frame might not be sent.
terminateOnDestroy = false;
duplex.destroy(err);
});
ws.once('close', function close() {
if (duplex.destroyed) return;
duplex.push(null);
});
duplex._destroy = function (err, callback) {
if (ws.readyState === ws.CLOSED) {
callback(err);
process.nextTick(emitClose, duplex);
return;
}
let called = false;
ws.once('error', function error(err) {
called = true;
callback(err);
});
ws.once('close', function close() {
if (!called) callback(err);
process.nextTick(emitClose, duplex);
});
if (terminateOnDestroy) ws.terminate();
};
duplex._final = function (callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._final(callback);
});
return;
}
// If the value of the `_socket` property is `null` it means that `ws` is a
// client websocket and the handshake failed. In fact, when this happens, a
// socket is never assigned to the websocket. Wait for the `'error'` event
// that will be emitted by the websocket.
if (ws._socket === null) return;
if (ws._socket._writableState.finished) {
callback();
if (duplex._readableState.endEmitted) duplex.destroy();
} else {
ws._socket.once('finish', function finish() {
// `duplex` is not destroyed here because the `'end'` event will be
// emitted on `duplex` after this `'finish'` event. The EOF signaling
// `null` chunk is, in fact, pushed when the websocket emits `'close'`.
callback();
});
ws.close();
}
};
duplex._read = function () {
if (ws.isPaused) ws.resume();
};
duplex._write = function (chunk, encoding, callback) {
if (ws.readyState === ws.CONNECTING) {
ws.once('open', function open() {
duplex._write(chunk, encoding, callback);
});
return;
}
ws.send(chunk, callback);
};
duplex.on('end', duplexOnEnd);
duplex.on('error', duplexOnError);
return duplex;
}
module.exports = createWebSocketStream;
+62
View File
@@ -0,0 +1,62 @@
'use strict';
const { tokenChars } = require('./validation');
/**
* Parses the `Sec-WebSocket-Protocol` header into a set of subprotocol names.
*
* @param {String} header The field value of the header
* @return {Set} The subprotocol names
* @public
*/
function parse(header) {
const protocols = new Set();
let start = -1;
let end = -1;
let i = 0;
for (i; i < header.length; i++) {
const code = header.charCodeAt(i);
if (end === -1 && tokenChars[code] === 1) {
if (start === -1) start = i;
} else if (
i !== 0 &&
(code === 0x20 /* ' ' */ || code === 0x09) /* '\t' */
) {
if (end === -1 && start !== -1) end = i;
} else if (code === 0x2c /* ',' */) {
if (start === -1) {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
if (end === -1) end = i;
const protocol = header.slice(start, end);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
start = end = -1;
} else {
throw new SyntaxError(`Unexpected character at index ${i}`);
}
}
if (start === -1 || end !== -1) {
throw new SyntaxError('Unexpected end of input');
}
const protocol = header.slice(start, i);
if (protocols.has(protocol)) {
throw new SyntaxError(`The "${protocol}" subprotocol is duplicated`);
}
protocols.add(protocol);
return protocols;
}
module.exports = { parse };
+152
View File
@@ -0,0 +1,152 @@
'use strict';
const { isUtf8 } = require('buffer');
const { hasBlob } = require('./constants');
//
// Allowed token characters:
//
// '!', '#', '$', '%', '&', ''', '*', '+', '-',
// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~'
//
// tokenChars[32] === 0 // ' '
// tokenChars[33] === 1 // '!'
// tokenChars[34] === 0 // '"'
// ...
//
// prettier-ignore
const tokenChars = [
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127
];
/**
* Checks if a status code is allowed in a close frame.
*
* @param {Number} code The status code
* @return {Boolean} `true` if the status code is valid, else `false`
* @public
*/
function isValidStatusCode(code) {
return (
(code >= 1000 &&
code <= 1014 &&
code !== 1004 &&
code !== 1005 &&
code !== 1006) ||
(code >= 3000 && code <= 4999)
);
}
/**
* Checks if a given buffer contains only correct UTF-8.
* Ported from https://www.cl.cam.ac.uk/%7Emgk25/ucs/utf8_check.c by
* Markus Kuhn.
*
* @param {Buffer} buf The buffer to check
* @return {Boolean} `true` if `buf` contains only correct UTF-8, else `false`
* @public
*/
function _isValidUTF8(buf) {
const len = buf.length;
let i = 0;
while (i < len) {
if ((buf[i] & 0x80) === 0) {
// 0xxxxxxx
i++;
} else if ((buf[i] & 0xe0) === 0xc0) {
// 110xxxxx 10xxxxxx
if (
i + 1 === len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i] & 0xfe) === 0xc0 // Overlong
) {
return false;
}
i += 2;
} else if ((buf[i] & 0xf0) === 0xe0) {
// 1110xxxx 10xxxxxx 10xxxxxx
if (
i + 2 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i] === 0xe0 && (buf[i + 1] & 0xe0) === 0x80) || // Overlong
(buf[i] === 0xed && (buf[i + 1] & 0xe0) === 0xa0) // Surrogate (U+D800 - U+DFFF)
) {
return false;
}
i += 3;
} else if ((buf[i] & 0xf8) === 0xf0) {
// 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
if (
i + 3 >= len ||
(buf[i + 1] & 0xc0) !== 0x80 ||
(buf[i + 2] & 0xc0) !== 0x80 ||
(buf[i + 3] & 0xc0) !== 0x80 ||
(buf[i] === 0xf0 && (buf[i + 1] & 0xf0) === 0x80) || // Overlong
(buf[i] === 0xf4 && buf[i + 1] > 0x8f) ||
buf[i] > 0xf4 // > U+10FFFF
) {
return false;
}
i += 4;
} else {
return false;
}
}
return true;
}
/**
* Determines whether a value is a `Blob`.
*
* @param {*} value The value to be tested
* @return {Boolean} `true` if `value` is a `Blob`, else `false`
* @private
*/
function isBlob(value) {
return (
hasBlob &&
typeof value === 'object' &&
typeof value.arrayBuffer === 'function' &&
typeof value.type === 'string' &&
typeof value.stream === 'function' &&
(value[Symbol.toStringTag] === 'Blob' ||
value[Symbol.toStringTag] === 'File')
);
}
module.exports = {
isBlob,
isValidStatusCode,
isValidUTF8: _isValidUTF8,
tokenChars
};
if (isUtf8) {
module.exports.isValidUTF8 = function (buf) {
return buf.length < 24 ? _isValidUTF8(buf) : isUtf8(buf);
};
} /* istanbul ignore else */ else if (!process.env.WS_NO_UTF_8_VALIDATE) {
try {
const isValidUTF8 = require('utf-8-validate');
module.exports.isValidUTF8 = function (buf) {
return buf.length < 32 ? _isValidUTF8(buf) : isValidUTF8(buf);
};
} catch (e) {
// Continue regardless of the error.
}
}
+554
View File
@@ -0,0 +1,554 @@
/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex$", "caughtErrors": "none" }] */
'use strict';
const EventEmitter = require('events');
const http = require('http');
const { Duplex } = require('stream');
const { createHash } = require('crypto');
const extension = require('./extension');
const PerMessageDeflate = require('./permessage-deflate');
const subprotocol = require('./subprotocol');
const WebSocket = require('./websocket');
const { CLOSE_TIMEOUT, GUID, kWebSocket } = require('./constants');
const keyRegex = /^[+/0-9A-Za-z]{22}==$/;
const RUNNING = 0;
const CLOSING = 1;
const CLOSED = 2;
/**
* Class representing a WebSocket server.
*
* @extends EventEmitter
*/
class WebSocketServer extends EventEmitter {
/**
* Create a `WebSocketServer` instance.
*
* @param {Object} options Configuration options
* @param {Boolean} [options.allowSynchronousEvents=true] Specifies whether
* any of the `'message'`, `'ping'`, and `'pong'` events can be emitted
* multiple times in the same tick
* @param {Boolean} [options.autoPong=true] Specifies whether or not to
* automatically send a pong in response to a ping
* @param {Number} [options.backlog=511] The maximum length of the queue of
* pending connections
* @param {Boolean} [options.clientTracking=true] Specifies whether or not to
* track clients
* @param {Number} [options.closeTimeout=30000] Duration in milliseconds to
* wait for the closing handshake to finish after `websocket.close()` is
* called
* @param {Function} [options.handleProtocols] A hook to handle protocols
* @param {String} [options.host] The hostname where to bind the server
* @param {Number} [options.maxPayload=104857600] The maximum allowed message
* size
* @param {Boolean} [options.noServer=false] Enable no server mode
* @param {String} [options.path] Accept only connections matching this path
* @param {(Boolean|Object)} [options.perMessageDeflate=false] Enable/disable
* permessage-deflate
* @param {Number} [options.port] The port where to bind the server
* @param {(http.Server|https.Server)} [options.server] A pre-created HTTP/S
* server to use
* @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or
* not to skip UTF-8 validation for text and close messages
* @param {Function} [options.verifyClient] A hook to reject connections
* @param {Function} [options.WebSocket=WebSocket] Specifies the `WebSocket`
* class to use. It must be the `WebSocket` class or class that extends it
* @param {Function} [callback] A listener for the `listening` event
*/
constructor(options, callback) {
super();
options = {
allowSynchronousEvents: true,
autoPong: true,
maxPayload: 100 * 1024 * 1024,
skipUTF8Validation: false,
perMessageDeflate: false,
handleProtocols: null,
clientTracking: true,
closeTimeout: CLOSE_TIMEOUT,
verifyClient: null,
noServer: false,
backlog: null, // use default (511 as implemented in net.js)
server: null,
host: null,
path: null,
port: null,
WebSocket,
...options
};
if (
(options.port == null && !options.server && !options.noServer) ||
(options.port != null && (options.server || options.noServer)) ||
(options.server && options.noServer)
) {
throw new TypeError(
'One and only one of the "port", "server", or "noServer" options ' +
'must be specified'
);
}
if (options.port != null) {
this._server = http.createServer((req, res) => {
const body = http.STATUS_CODES[426];
res.writeHead(426, {
'Content-Length': body.length,
'Content-Type': 'text/plain'
});
res.end(body);
});
this._server.listen(
options.port,
options.host,
options.backlog,
callback
);
} else if (options.server) {
this._server = options.server;
}
if (this._server) {
const emitConnection = this.emit.bind(this, 'connection');
this._removeListeners = addListeners(this._server, {
listening: this.emit.bind(this, 'listening'),
error: this.emit.bind(this, 'error'),
upgrade: (req, socket, head) => {
this.handleUpgrade(req, socket, head, emitConnection);
}
});
}
if (options.perMessageDeflate === true) options.perMessageDeflate = {};
if (options.clientTracking) {
this.clients = new Set();
this._shouldEmitClose = false;
}
this.options = options;
this._state = RUNNING;
}
/**
* Returns the bound address, the address family name, and port of the server
* as reported by the operating system if listening on an IP socket.
* If the server is listening on a pipe or UNIX domain socket, the name is
* returned as a string.
*
* @return {(Object|String|null)} The address of the server
* @public
*/
address() {
if (this.options.noServer) {
throw new Error('The server is operating in "noServer" mode');
}
if (!this._server) return null;
return this._server.address();
}
/**
* Stop the server from accepting new connections and emit the `'close'` event
* when all existing connections are closed.
*
* @param {Function} [cb] A one-time listener for the `'close'` event
* @public
*/
close(cb) {
if (this._state === CLOSED) {
if (cb) {
this.once('close', () => {
cb(new Error('The server is not running'));
});
}
process.nextTick(emitClose, this);
return;
}
if (cb) this.once('close', cb);
if (this._state === CLOSING) return;
this._state = CLOSING;
if (this.options.noServer || this.options.server) {
if (this._server) {
this._removeListeners();
this._removeListeners = this._server = null;
}
if (this.clients) {
if (!this.clients.size) {
process.nextTick(emitClose, this);
} else {
this._shouldEmitClose = true;
}
} else {
process.nextTick(emitClose, this);
}
} else {
const server = this._server;
this._removeListeners();
this._removeListeners = this._server = null;
//
// The HTTP/S server was created internally. Close it, and rely on its
// `'close'` event.
//
server.close(() => {
emitClose(this);
});
}
}
/**
* See if a given request should be handled by this server instance.
*
* @param {http.IncomingMessage} req Request object to inspect
* @return {Boolean} `true` if the request is valid, else `false`
* @public
*/
shouldHandle(req) {
if (this.options.path) {
const index = req.url.indexOf('?');
const pathname = index !== -1 ? req.url.slice(0, index) : req.url;
if (pathname !== this.options.path) return false;
}
return true;
}
/**
* Handle a HTTP Upgrade request.
*
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The network socket between the server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @public
*/
handleUpgrade(req, socket, head, cb) {
socket.on('error', socketOnError);
const key = req.headers['sec-websocket-key'];
const upgrade = req.headers.upgrade;
const version = +req.headers['sec-websocket-version'];
if (req.method !== 'GET') {
const message = 'Invalid HTTP method';
abortHandshakeOrEmitwsClientError(this, req, socket, 405, message);
return;
}
if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') {
const message = 'Invalid Upgrade header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
if (key === undefined || !keyRegex.test(key)) {
const message = 'Missing or invalid Sec-WebSocket-Key header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
if (version !== 13 && version !== 8) {
const message = 'Missing or invalid Sec-WebSocket-Version header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message, {
'Sec-WebSocket-Version': '13, 8'
});
return;
}
if (!this.shouldHandle(req)) {
abortHandshake(socket, 400);
return;
}
const secWebSocketProtocol = req.headers['sec-websocket-protocol'];
let protocols = new Set();
if (secWebSocketProtocol !== undefined) {
try {
protocols = subprotocol.parse(secWebSocketProtocol);
} catch (err) {
const message = 'Invalid Sec-WebSocket-Protocol header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
}
const secWebSocketExtensions = req.headers['sec-websocket-extensions'];
const extensions = {};
if (
this.options.perMessageDeflate &&
secWebSocketExtensions !== undefined
) {
const perMessageDeflate = new PerMessageDeflate({
...this.options.perMessageDeflate,
isServer: true,
maxPayload: this.options.maxPayload
});
try {
const offers = extension.parse(secWebSocketExtensions);
if (offers[PerMessageDeflate.extensionName]) {
perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]);
extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
}
} catch (err) {
const message =
'Invalid or unacceptable Sec-WebSocket-Extensions header';
abortHandshakeOrEmitwsClientError(this, req, socket, 400, message);
return;
}
}
//
// Optionally call external client verification handler.
//
if (this.options.verifyClient) {
const info = {
origin:
req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
secure: !!(req.socket.authorized || req.socket.encrypted),
req
};
if (this.options.verifyClient.length === 2) {
this.options.verifyClient(info, (verified, code, message, headers) => {
if (!verified) {
return abortHandshake(socket, code || 401, message, headers);
}
this.completeUpgrade(
extensions,
key,
protocols,
req,
socket,
head,
cb
);
});
return;
}
if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
}
this.completeUpgrade(extensions, key, protocols, req, socket, head, cb);
}
/**
* Upgrade the connection to WebSocket.
*
* @param {Object} extensions The accepted extensions
* @param {String} key The value of the `Sec-WebSocket-Key` header
* @param {Set} protocols The subprotocols
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The network socket between the server and client
* @param {Buffer} head The first packet of the upgraded stream
* @param {Function} cb Callback
* @throws {Error} If called more than once with the same socket
* @private
*/
completeUpgrade(extensions, key, protocols, req, socket, head, cb) {
//
// Destroy the socket if the client has already sent a FIN packet.
//
if (!socket.readable || !socket.writable) return socket.destroy();
if (socket[kWebSocket]) {
throw new Error(
'server.handleUpgrade() was called more than once with the same ' +
'socket, possibly due to a misconfiguration'
);
}
if (this._state > RUNNING) return abortHandshake(socket, 503);
const digest = createHash('sha1')
.update(key + GUID)
.digest('base64');
const headers = [
'HTTP/1.1 101 Switching Protocols',
'Upgrade: websocket',
'Connection: Upgrade',
`Sec-WebSocket-Accept: ${digest}`
];
const ws = new this.options.WebSocket(null, undefined, this.options);
if (protocols.size) {
//
// Optionally call external protocol selection handler.
//
const protocol = this.options.handleProtocols
? this.options.handleProtocols(protocols, req)
: protocols.values().next().value;
if (protocol) {
headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
ws._protocol = protocol;
}
}
if (extensions[PerMessageDeflate.extensionName]) {
const params = extensions[PerMessageDeflate.extensionName].params;
const value = extension.format({
[PerMessageDeflate.extensionName]: [params]
});
headers.push(`Sec-WebSocket-Extensions: ${value}`);
ws._extensions = extensions;
}
//
// Allow external modification/inspection of handshake headers.
//
this.emit('headers', headers, req);
socket.write(headers.concat('\r\n').join('\r\n'));
socket.removeListener('error', socketOnError);
ws.setSocket(socket, head, {
allowSynchronousEvents: this.options.allowSynchronousEvents,
maxPayload: this.options.maxPayload,
skipUTF8Validation: this.options.skipUTF8Validation
});
if (this.clients) {
this.clients.add(ws);
ws.on('close', () => {
this.clients.delete(ws);
if (this._shouldEmitClose && !this.clients.size) {
process.nextTick(emitClose, this);
}
});
}
cb(ws, req);
}
}
module.exports = WebSocketServer;
/**
* Add event listeners on an `EventEmitter` using a map of <event, listener>
* pairs.
*
* @param {EventEmitter} server The event emitter
* @param {Object.<String, Function>} map The listeners to add
* @return {Function} A function that will remove the added listeners when
* called
* @private
*/
function addListeners(server, map) {
for (const event of Object.keys(map)) server.on(event, map[event]);
return function removeListeners() {
for (const event of Object.keys(map)) {
server.removeListener(event, map[event]);
}
};
}
/**
* Emit a `'close'` event on an `EventEmitter`.
*
* @param {EventEmitter} server The event emitter
* @private
*/
function emitClose(server) {
server._state = CLOSED;
server.emit('close');
}
/**
* Handle socket errors.
*
* @private
*/
function socketOnError() {
this.destroy();
}
/**
* Close the connection when preconditions are not fulfilled.
*
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} [message] The HTTP response body
* @param {Object} [headers] Additional HTTP response headers
* @private
*/
function abortHandshake(socket, code, message, headers) {
//
// The socket is writable unless the user destroyed or ended it before calling
// `server.handleUpgrade()` or in the `verifyClient` function, which is a user
// error. Handling this does not make much sense as the worst that can happen
// is that some of the data written by the user might be discarded due to the
// call to `socket.end()` below, which triggers an `'error'` event that in
// turn causes the socket to be destroyed.
//
message = message || http.STATUS_CODES[code];
headers = {
Connection: 'close',
'Content-Type': 'text/html',
'Content-Length': Buffer.byteLength(message),
...headers
};
socket.once('finish', socket.destroy);
socket.end(
`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` +
Object.keys(headers)
.map((h) => `${h}: ${headers[h]}`)
.join('\r\n') +
'\r\n\r\n' +
message
);
}
/**
* Emit a `'wsClientError'` event on a `WebSocketServer` if there is at least
* one listener for it, otherwise call `abortHandshake()`.
*
* @param {WebSocketServer} server The WebSocket server
* @param {http.IncomingMessage} req The request object
* @param {Duplex} socket The socket of the upgrade request
* @param {Number} code The HTTP response status code
* @param {String} message The HTTP response body
* @param {Object} [headers] The HTTP response headers
* @private
*/
function abortHandshakeOrEmitwsClientError(
server,
req,
socket,
code,
message,
headers
) {
if (server.listenerCount('wsClientError')) {
const err = new Error(message);
Error.captureStackTrace(err, abortHandshakeOrEmitwsClientError);
server.emit('wsClientError', err, socket, req);
} else {
abortHandshake(socket, code, message, headers);
}
}
+1393
View File
File diff suppressed because it is too large Load Diff
+70
View File
@@ -0,0 +1,70 @@
{
"name": "ws",
"version": "8.20.0",
"description": "Simple to use, blazing fast and thoroughly tested websocket client and server for Node.js",
"keywords": [
"HyBi",
"Push",
"RFC-6455",
"WebSocket",
"WebSockets",
"real-time"
],
"homepage": "https://github.com/websockets/ws",
"bugs": "https://github.com/websockets/ws/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/websockets/ws.git"
},
"author": "Einar Otto Stangvik <einaros@gmail.com> (http://2x.io)",
"license": "MIT",
"main": "index.js",
"exports": {
".": {
"browser": "./browser.js",
"import": "./wrapper.mjs",
"require": "./index.js"
},
"./package.json": "./package.json"
},
"browser": "browser.js",
"engines": {
"node": ">=10.0.0"
},
"files": [
"browser.js",
"index.js",
"lib/*.js",
"wrapper.mjs"
],
"scripts": {
"test": "nyc --reporter=lcov --reporter=text mocha --throw-deprecation test/*.test.js",
"integration": "mocha --throw-deprecation test/*.integration.js",
"lint": "eslint . && prettier --check --ignore-path .gitignore \"**/*.{json,md,yaml,yml}\""
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"benchmark": "^2.1.4",
"bufferutil": "^4.0.1",
"eslint": "^10.0.1",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.0.0",
"globals": "^17.0.0",
"mocha": "^8.4.0",
"nyc": "^15.0.0",
"prettier": "^3.0.0",
"utf-8-validate": "^6.0.0"
}
}
+21
View File
@@ -0,0 +1,21 @@
import createWebSocketStream from './lib/stream.js';
import extension from './lib/extension.js';
import PerMessageDeflate from './lib/permessage-deflate.js';
import Receiver from './lib/receiver.js';
import Sender from './lib/sender.js';
import subprotocol from './lib/subprotocol.js';
import WebSocket from './lib/websocket.js';
import WebSocketServer from './lib/websocket-server.js';
export {
createWebSocketStream,
extension,
PerMessageDeflate,
Receiver,
Sender,
subprotocol,
WebSocket,
WebSocketServer
};
export default WebSocket;
+36
View File
@@ -0,0 +1,36 @@
{
"name": "tankwar-server",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "tankwar-server",
"version": "1.0.0",
"dependencies": {
"ws": "^8.16.0"
}
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"name": "tankwar-server",
"version": "1.0.0",
"description": "WebSocket server for Tank War PVP multiplayer",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node index.js"
},
"dependencies": {
"ws": "^8.16.0"
}
}