first commit
This commit is contained in:
@@ -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)。
|
||||
@@ -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 文案_
|
||||
@@ -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 提供音效开关、音乐开关、振动开关等选项。
|
||||
|
||||
---
|
||||
|
||||
### 需求 13:3v3 对战模式
|
||||
|
||||
**用户故事:** 作为一名玩家,我希望能与好友组队进行 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分钟内理解核心操作。
|
||||
@@ -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:主菜单场景(MenuScene)i18n 化
|
||||
|
||||
#### 验收标准
|
||||
|
||||
| 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:双人对战房间场景(RoomScene)i18n 化
|
||||
|
||||
#### 验收标准
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
### 需求 4:3v3 团队房间场景(TeamRoomScene)i18n 化
|
||||
|
||||
#### 验收标准
|
||||
|
||||
| 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:双人对战游戏场景(PvpGameScene)i18n 化
|
||||
|
||||
#### 验收标准
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
### 需求 6:3v3 团队对战游戏场景(TeamGameScene)i18n 化
|
||||
|
||||
#### 验收标准
|
||||
|
||||
| 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:双人对战结算场景(PvpResultScene)i18n 化
|
||||
|
||||
#### 验收标准
|
||||
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
||||
### 需求 8:3v3 团队结算场景(TeamResultScene)i18n 化
|
||||
|
||||
#### 验收标准
|
||||
|
||||
| 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:经典模式游戏场景(GameScene)i18n 化
|
||||
|
||||
#### 验收标准
|
||||
|
||||
| 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)'` → `'⭐ MVP:xxx(x 击杀)'`
|
||||
- 替换段位积分 `'📈 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_
|
||||
@@ -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的双轨变现,适合快速验证和迭代。
|
||||
@@ -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万DAU,eCPM ¥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%
|
||||
|
||||
**总结**:本方案通过**分层付费设计**(免费看广告→小额内购→订阅通行证)覆盖全用户层级,利用微信社交链**降低获客成本**,在保持玩法核心乐趣的同时,实现**月流水超百万**的商业目标。关键成功因素在于平衡广告频次与用户体验,通过赛季内容持续拉动活跃与付费。
|
||||
@@ -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);
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"deviceOrientation": "landscape",
|
||||
"showStatusBar": false,
|
||||
"networkTimeout": {
|
||||
"request": 10000,
|
||||
"connectSocket": 10000,
|
||||
"uploadFile": 10000,
|
||||
"downloadFile": 10000
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
// BattlePassData - DEPRECATED (removed in monetization-lite)
|
||||
module.exports = {};
|
||||
@@ -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 4–16),
|
||||
* with additional terrain on the flanks (cols 0–3 and cols 17–20).
|
||||
*
|
||||
* 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 };
|
||||
@@ -0,0 +1,2 @@
|
||||
// SkinData - DEPRECATED (removed in monetization-lite)
|
||||
module.exports = {};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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 金币!',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+29
@@ -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
@@ -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
@@ -0,0 +1,548 @@
|
||||
# ws: a Node.js WebSocket library
|
||||
|
||||
[](https://www.npmjs.com/package/ws)
|
||||
[](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster)
|
||||
[](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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+70
@@ -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
@@ -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;
|
||||
Generated
+36
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user