commit cc2e7b9bb08d0e1478f0d0f083a5619f1efde63a Author: jakciehan Date: Fri Apr 10 22:59:39 2026 +0800 first commit diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..9946d37 Binary files /dev/null and b/.DS_Store differ diff --git a/.cloudbase/container/debug.json b/.cloudbase/container/debug.json new file mode 100644 index 0000000..0d44458 --- /dev/null +++ b/.cloudbase/container/debug.json @@ -0,0 +1 @@ +{"containers":[],"config":{}} \ No newline at end of file diff --git a/.codebuddy/plan/monetization-lite/requirements.md b/.codebuddy/plan/monetization-lite/requirements.md new file mode 100644 index 0000000..f16b775 --- /dev/null +++ b/.codebuddy/plan/monetization-lite/requirements.md @@ -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金币) diff --git a/.codebuddy/plan/monetization-lite/task-item.md b/.codebuddy/plan/monetization-lite/task-item.md new file mode 100644 index 0000000..d2a5095 --- /dev/null +++ b/.codebuddy/plan/monetization-lite/task-item.md @@ -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_ diff --git a/.codebuddy/plan/monetization/requirements.md b/.codebuddy/plan/monetization/requirements.md new file mode 100644 index 0000000..e264f60 --- /dev/null +++ b/.codebuddy/plan/monetization/requirements.md @@ -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)。 diff --git a/.codebuddy/plan/monetization/task-item.md b/.codebuddy/plan/monetization/task-item.md new file mode 100644 index 0000000..44a6eca --- /dev/null +++ b/.codebuddy/plan/monetization/task-item.md @@ -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 文案_ diff --git a/.codebuddy/plan/tankwar/requirements.md b/.codebuddy/plan/tankwar/requirements.md new file mode 100644 index 0000000..68596d0 --- /dev/null +++ b/.codebuddy/plan/tankwar/requirements.md @@ -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分钟内理解核心操作。 diff --git a/.codebuddy/plan/tankwar/task-item.md b/.codebuddy/plan/tankwar/task-item.md new file mode 100644 index 0000000..1c2f8f5 --- /dev/null +++ b/.codebuddy/plan/tankwar/task-item.md @@ -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_ diff --git a/.codebuddy/plan/ui-text-localization/requirements.md b/.codebuddy/plan/ui-text-localization/requirements.md new file mode 100644 index 0000000..b09e4bc --- /dev/null +++ b/.codebuddy/plan/ui-text-localization/requirements.md @@ -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. 文案在各场景中布局合理,无溢出或错位现象。 diff --git a/.codebuddy/plan/ui-text-localization/task-item.md b/.codebuddy/plan/ui-text-localization/task-item.md new file mode 100644 index 0000000..ae1601b --- /dev/null +++ b/.codebuddy/plan/ui-text-localization/task-item.md @@ -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_ diff --git a/docs/AudioManager_说明文档.md b/docs/AudioManager_说明文档.md new file mode 100644 index 0000000..fcd70c2 --- /dev/null +++ b/docs/AudioManager_说明文档.md @@ -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 配置文件读取波形参数) diff --git a/docs/坦克大战经典游戏 - 极简商业化方案.md b/docs/坦克大战经典游戏 - 极简商业化方案.md new file mode 100644 index 0000000..70e8d7a --- /dev/null +++ b/docs/坦克大战经典游戏 - 极简商业化方案.md @@ -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的双轨变现,适合快速验证和迭代。 \ No newline at end of file diff --git a/docs/坦克探险商业化方案.md b/docs/坦克探险商业化方案.md new file mode 100644 index 0000000..1e8bf81 --- /dev/null +++ b/docs/坦克探险商业化方案.md @@ -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钻
¥30/360钻
¥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. 付费节奏 +| 阶段 | 商业化重点 | 付费点设计 | +| :--- | :--- | :--- | +| **新手期**
(1-3天) | 建立付费习惯 | 1元首充礼包、去广告特权推荐 | +| **成长期**
(4-14天) | 提高付费深度 | 月卡、钻石充值、皮肤促销 | +| **成熟期**
(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% + +**总结**:本方案通过**分层付费设计**(免费看广告→小额内购→订阅通行证)覆盖全用户层级,利用微信社交链**降低获客成本**,在保持玩法核心乐趣的同时,实现**月流水超百万**的商业目标。关键成功因素在于平衡广告频次与用户体验,通过赛季内容持续拉动活跃与付费。 \ No newline at end of file diff --git a/game.js b/game.js new file mode 100644 index 0000000..edb7d56 --- /dev/null +++ b/game.js @@ -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); diff --git a/game.json b/game.json new file mode 100644 index 0000000..c94fc29 --- /dev/null +++ b/game.json @@ -0,0 +1,10 @@ +{ + "deviceOrientation": "landscape", + "showStatusBar": false, + "networkTimeout": { + "request": 10000, + "connectSocket": 10000, + "uploadFile": 10000, + "downloadFile": 10000 + } +} diff --git a/js/base/EventBus.js b/js/base/EventBus.js new file mode 100644 index 0000000..b6d2673 --- /dev/null +++ b/js/base/EventBus.js @@ -0,0 +1,84 @@ +/** + * EventBus.js + * Simple publish/subscribe event system for decoupled communication + * between game systems. + */ + +class EventBus { + constructor() { + /** @type {Map>} */ + 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; diff --git a/js/base/GameGlobal.js b/js/base/GameGlobal.js new file mode 100644 index 0000000..9a14dd8 --- /dev/null +++ b/js/base/GameGlobal.js @@ -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, +}; diff --git a/js/base/ObjectPool.js b/js/base/ObjectPool.js new file mode 100644 index 0000000..18e32e1 --- /dev/null +++ b/js/base/ObjectPool.js @@ -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; diff --git a/js/data/BattlePassData.js b/js/data/BattlePassData.js new file mode 100644 index 0000000..6f3bff7 --- /dev/null +++ b/js/data/BattlePassData.js @@ -0,0 +1,2 @@ +// BattlePassData - DEPRECATED (removed in monetization-lite) +module.exports = {}; diff --git a/js/data/LevelData.js b/js/data/LevelData.js new file mode 100644 index 0000000..3e66f3e --- /dev/null +++ b/js/data/LevelData.js @@ -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 }; diff --git a/js/data/SkinData.js b/js/data/SkinData.js new file mode 100644 index 0000000..325285a --- /dev/null +++ b/js/data/SkinData.js @@ -0,0 +1,2 @@ +// SkinData - DEPRECATED (removed in monetization-lite) +module.exports = {}; diff --git a/js/entities/BotTank.js b/js/entities/BotTank.js new file mode 100644 index 0000000..4897081 --- /dev/null +++ b/js/entities/BotTank.js @@ -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; diff --git a/js/entities/Bullet.js b/js/entities/Bullet.js new file mode 100644 index 0000000..a0e9270 --- /dev/null +++ b/js/entities/Bullet.js @@ -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; diff --git a/js/entities/EnemyTank.js b/js/entities/EnemyTank.js new file mode 100644 index 0000000..30e709c --- /dev/null +++ b/js/entities/EnemyTank.js @@ -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; diff --git a/js/entities/Explosion.js b/js/entities/Explosion.js new file mode 100644 index 0000000..849ddf1 --- /dev/null +++ b/js/entities/Explosion.js @@ -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; diff --git a/js/entities/PlayerTank.js b/js/entities/PlayerTank.js new file mode 100644 index 0000000..d232cc8 --- /dev/null +++ b/js/entities/PlayerTank.js @@ -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; diff --git a/js/entities/PowerUp.js b/js/entities/PowerUp.js new file mode 100644 index 0000000..12dfa38 --- /dev/null +++ b/js/entities/PowerUp.js @@ -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; diff --git a/js/entities/Tank.js b/js/entities/Tank.js new file mode 100644 index 0000000..4474aa0 --- /dev/null +++ b/js/entities/Tank.js @@ -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; diff --git a/js/i18n/I18n.js b/js/i18n/I18n.js new file mode 100644 index 0000000..79eab34 --- /dev/null +++ b/js/i18n/I18n.js @@ -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 }; diff --git a/js/i18n/en.js b/js/i18n/en.js new file mode 100644 index 0000000..758d6e8 --- /dev/null +++ b/js/i18n/en.js @@ -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!', +}; diff --git a/js/i18n/zh.js b/js/i18n/zh.js new file mode 100644 index 0000000..d912508 --- /dev/null +++ b/js/i18n/zh.js @@ -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 金币!', +}; diff --git a/js/managers/AdManager.js b/js/managers/AdManager.js new file mode 100644 index 0000000..458c387 --- /dev/null +++ b/js/managers/AdManager.js @@ -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 + this._sceneCooldowns = new Map(); + + // Daily scene count tracking: Map + 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; diff --git a/js/managers/AudioManager.js b/js/managers/AudioManager.js new file mode 100644 index 0000000..3c5633c --- /dev/null +++ b/js/managers/AudioManager.js @@ -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} */ + 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; diff --git a/js/managers/BattlePassManager.js b/js/managers/BattlePassManager.js new file mode 100644 index 0000000..9354575 --- /dev/null +++ b/js/managers/BattlePassManager.js @@ -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; diff --git a/js/managers/BuffManager.js b/js/managers/BuffManager.js new file mode 100644 index 0000000..82a2c88 --- /dev/null +++ b/js/managers/BuffManager.js @@ -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} 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; diff --git a/js/managers/CollisionManager.js b/js/managers/CollisionManager.js new file mode 100644 index 0000000..b9f1a14 --- /dev/null +++ b/js/managers/CollisionManager.js @@ -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} entities.enemies + * @param {Array} 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; diff --git a/js/managers/ComplianceManager.js b/js/managers/ComplianceManager.js new file mode 100644 index 0000000..812b282 --- /dev/null +++ b/js/managers/ComplianceManager.js @@ -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; diff --git a/js/managers/CurrencyManager.js b/js/managers/CurrencyManager.js new file mode 100644 index 0000000..39c866b --- /dev/null +++ b/js/managers/CurrencyManager.js @@ -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; diff --git a/js/managers/MapManager.js b/js/managers/MapManager.js new file mode 100644 index 0000000..b151883 --- /dev/null +++ b/js/managers/MapManager.js @@ -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; diff --git a/js/managers/NetworkManager.js b/js/managers/NetworkManager.js new file mode 100644 index 0000000..b340f14 --- /dev/null +++ b/js/managers/NetworkManager.js @@ -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>} */ + 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} 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; diff --git a/js/managers/PaymentManager.js b/js/managers/PaymentManager.js new file mode 100644 index 0000000..24a7aa8 --- /dev/null +++ b/js/managers/PaymentManager.js @@ -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; diff --git a/js/managers/PromotionManager.js b/js/managers/PromotionManager.js new file mode 100644 index 0000000..0d1d435 --- /dev/null +++ b/js/managers/PromotionManager.js @@ -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; diff --git a/js/managers/ResourceManager.js b/js/managers/ResourceManager.js new file mode 100644 index 0000000..7693a3b --- /dev/null +++ b/js/managers/ResourceManager.js @@ -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} */ + 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} 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; diff --git a/js/managers/SceneManager.js b/js/managers/SceneManager.js new file mode 100644 index 0000000..8d2f49d --- /dev/null +++ b/js/managers/SceneManager.js @@ -0,0 +1,99 @@ +/** + * SceneManager.js + * Manages scene registration, switching, and lifecycle (enter/exit/update/render). + */ + +class SceneManager { + constructor() { + /** @type {Map} 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; diff --git a/js/managers/ShareManager.js b/js/managers/ShareManager.js new file mode 100644 index 0000000..a914ea6 --- /dev/null +++ b/js/managers/ShareManager.js @@ -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; diff --git a/js/managers/SkinManager.js b/js/managers/SkinManager.js new file mode 100644 index 0000000..db47008 --- /dev/null +++ b/js/managers/SkinManager.js @@ -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} 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} + */ + 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; diff --git a/js/managers/SpawnManager.js b/js/managers/SpawnManager.js new file mode 100644 index 0000000..6ce979d --- /dev/null +++ b/js/managers/SpawnManager.js @@ -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} 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; diff --git a/js/managers/StaminaManager.js b/js/managers/StaminaManager.js new file mode 100644 index 0000000..2729808 --- /dev/null +++ b/js/managers/StaminaManager.js @@ -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; diff --git a/js/managers/StorageManager.js b/js/managers/StorageManager.js new file mode 100644 index 0000000..f4a57fa --- /dev/null +++ b/js/managers/StorageManager.js @@ -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; diff --git a/js/scenes/BattlePassScene.js b/js/scenes/BattlePassScene.js new file mode 100644 index 0000000..c26e28b --- /dev/null +++ b/js/scenes/BattlePassScene.js @@ -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; diff --git a/js/scenes/BuffSelectScene.js b/js/scenes/BuffSelectScene.js new file mode 100644 index 0000000..b5608d2 --- /dev/null +++ b/js/scenes/BuffSelectScene.js @@ -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; diff --git a/js/scenes/GameScene.js b/js/scenes/GameScene.js new file mode 100644 index 0000000..bd19d72 --- /dev/null +++ b/js/scenes/GameScene.js @@ -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; diff --git a/js/scenes/MenuScene.js b/js/scenes/MenuScene.js new file mode 100644 index 0000000..1847fd1 --- /dev/null +++ b/js/scenes/MenuScene.js @@ -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; diff --git a/js/scenes/RankingScene.js b/js/scenes/RankingScene.js new file mode 100644 index 0000000..44d7c2b --- /dev/null +++ b/js/scenes/RankingScene.js @@ -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; diff --git a/js/scenes/ResultScene.js b/js/scenes/ResultScene.js new file mode 100644 index 0000000..0b8b1bf --- /dev/null +++ b/js/scenes/ResultScene.js @@ -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; diff --git a/js/scenes/RoomScene.js b/js/scenes/RoomScene.js new file mode 100644 index 0000000..9a1feee --- /dev/null +++ b/js/scenes/RoomScene.js @@ -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; diff --git a/js/scenes/SettingsScene.js b/js/scenes/SettingsScene.js new file mode 100644 index 0000000..6d9dd4a --- /dev/null +++ b/js/scenes/SettingsScene.js @@ -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; diff --git a/js/scenes/ShopScene.js b/js/scenes/ShopScene.js new file mode 100644 index 0000000..f8432ba --- /dev/null +++ b/js/scenes/ShopScene.js @@ -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; diff --git a/js/scenes/TeamGameScene.js b/js/scenes/TeamGameScene.js new file mode 100644 index 0000000..e6cbd2c --- /dev/null +++ b/js/scenes/TeamGameScene.js @@ -0,0 +1,1480 @@ +/** + * TeamGameScene.js + * Unified battle scene for online multiplayer (supports 1v1 PVP and 3v3 Team modes). + * Configurable via battleMode parameter ('1v1' or '3v3'). + * Base HP system, unlimited respawns, network-synced input, team-based win/lose logic. + */ + +const { + SCREEN_WIDTH, + SCREEN_HEIGHT, + COLORS, + SCENE, + MAP_OFFSET_X, + MAP_OFFSET_Y, + MAP_WIDTH, + MAP_HEIGHT, + TILE_SIZE, + DIRECTION, + DIR_VECTORS, + TERRAIN, + NET_MSG, + TEAM_RESPAWN_DELAY, + TEAM_BASE_HP, + TEAM_SIZE, + TANK_CONFIG, + TANK_TYPE, + PVP_BASE_HP, + PVP_RESPAWN_DELAY, + BATTLE_CONFIG, +} = require('../base/GameGlobal'); +const { t } = require('../i18n/I18n'); + +const ObjectPool = require('../base/ObjectPool'); +const MapManager = require('../managers/MapManager'); +const PlayerTank = require('../entities/PlayerTank'); +const BotTank = require('../entities/BotTank'); +const Bullet = require('../entities/Bullet'); +const Explosion = require('../entities/Explosion'); +const Joystick = require('../ui/Joystick'); +const FireButton = require('../ui/FireButton'); +const { getTeamMap, getPvpMap } = require('../data/LevelData'); + +// Team colors +const TEAM_A_COLOR = '#4A90D9'; // blue +const TEAM_B_COLOR = '#E94560'; // red +const LOCAL_PLAYER_COLOR = '#FFD700'; // gold highlight + +// 1v1 PVP player colors +const PLAYER1_COLOR = '#FFD700'; // gold +const PLAYER2_COLOR = '#00BFFF'; // deep sky blue + +const TeamGameScene = { + _initialized: false, + _gameOver: false, + _paused: false, + _winner: '', // '' | 'A' | 'B' + _winReason: '', + + // Reconnection state + _isDisconnected: false, + _reconnectTimer: 0, + _reconnectAttempts: 0, + _maxReconnectAttempts: 5, + _reconnectInterval: 3, // seconds between attempts + + // Network + _networkManager: null, + _myPlayerId: '', + _myTeam: '', // 'A' or 'B' + _teamId: '', + _unsubscribers: [], + + // Map + _mapManager: null, + _mapData: null, + + // Controls + _joystick: null, + _fireButton: null, + + // Players: { playerId, tank, isBot, team, isLocal, spawnPoint, respawnTimer } + _players: [], + _localPlayer: null, + + // Entity lists + _bullets: [], + _explosions: [], + + // Object pools + _bulletPool: null, + _explosionPool: null, + + // Battle config (1v1 or 3v3) + _battleMode: '3v3', // '1v1' or '3v3' + _battleConfig: null, + + // Game state + _elapsedTime: 0, // seconds since game started (count up) + _teamABaseHp: TEAM_BASE_HP, + _teamBBaseHp: TEAM_BASE_HP, + _gameOverDelay: 0, + _gameOverDelayDuration: 3, + + // Stats + _stats: {}, // { playerId: { kills, deaths, assists, baseDamage } } + + // Sync + _syncTimer: 0, + _syncInterval: 0.05, + _lastSentInput: null, + + // Remote state targets for interpolation + _remoteTargets: {}, // { playerId: { x, y, direction } } + + enter(params) { + this._teamId = (params && params.teamId) || ''; + this._myPlayerId = (params && params.myPlayerId) || ''; + this._gameOver = false; + this._paused = false; + this._winner = ''; + this._winReason = ''; + this._gameOverDelay = 0; + this._elapsedTime = 0; + this._syncTimer = 0; + this._lastSentInput = null; + this._stats = {}; + this._remoteTargets = {}; + this._isDisconnected = false; + this._reconnectTimer = 0; + this._reconnectAttempts = 0; + + // Determine battle mode from params + this._battleMode = (params && params.battleMode) || '3v3'; + this._battleConfig = BATTLE_CONFIG[this._battleMode] || BATTLE_CONFIG['3v3']; + + const baseHp = (params && params.teamABaseHp) || this._battleConfig.baseHp; + this._teamABaseHp = baseHp; + this._teamBBaseHp = (params && params.teamBBaseHp) || baseHp; + + this._networkManager = GameGlobal.networkManager; + + // Initialize object pools + this._bulletPool = new ObjectPool(() => new Bullet(), null, 40); + this._explosionPool = new ObjectPool(() => new Explosion(), null, 20); + this._bullets = []; + this._explosions = []; + + // Load map based on battle mode + if (this._battleConfig.mapPool === 'pvp') { + this._mapData = getPvpMap(params && params.mapId, params && params.roomId); + } else { + this._mapData = getTeamMap(params && params.mapId); + } + this._mapManager = new MapManager(); + this._mapManager.loadGrid(this._mapData.grid); + + // Determine which team the local player is on + const teamAMembers = (params && params.teamA) || []; + const teamBMembers = (params && params.teamB) || []; + + this._myTeam = teamAMembers.find(m => m.playerId === this._myPlayerId) ? 'A' : 'B'; + + // Create all player tanks + this._players = []; + this._createTeamPlayers(teamAMembers, 'A', this._mapData.teamASpawns); + this._createTeamPlayers(teamBMembers, 'B', this._mapData.teamBSpawns); + + // Find local player + this._localPlayer = this._players.find(p => p.isLocal); + + // Initialize stats + for (const p of this._players) { + this._stats[p.playerId] = { kills: 0, deaths: 0, assists: 0, baseDamage: 0 }; + } + + // Initialize controls + this._joystick = new Joystick(); + this._fireButton = new FireButton(); + this._fireButton.onFire(() => this._localFire()); + + // Setup network events + this._setupNetworkEvents(); + + this._initialized = true; + console.log(`[TeamGameScene] Started (${this._battleMode}). Team: ${this._teamId}, Player: ${this._myPlayerId}, MyTeam: ${this._myTeam}`); + }, + + _createTeamPlayers(members, team, spawnPoints) { + for (let i = 0; i < members.length; i++) { + const member = members[i]; + const spawn = spawnPoints[i % spawnPoints.length]; + const isLocal = member.playerId === this._myPlayerId; + const isMyTeam = team === this._myTeam; + const isBot = member.isBot || false; + + // Determine color based on battle mode + let tankColor; + if (this._battleMode === '1v1') { + // 1v1: use P1/P2 colors based on team + if (isLocal) { + tankColor = team === 'A' ? PLAYER1_COLOR : PLAYER2_COLOR; + } else { + tankColor = team === 'A' ? PLAYER1_COLOR : PLAYER2_COLOR; + } + } else { + // 3v3: local=gold, ally=blue, enemy=red + if (isLocal) { + tankColor = LOCAL_PLAYER_COLOR; + } else if (isMyTeam) { + tankColor = TEAM_A_COLOR; + } else { + tankColor = TEAM_B_COLOR; + } + } + + let tank; + if (isBot) { + // Use BotTank for AI-controlled players + tank = new BotTank({ + col: spawn.col, + row: spawn.row, + team, + playerId: member.playerId, + color: tankColor, + }); + // Set target base: bots attack the enemy base + const enemyBase = team === 'A' ? this._mapData.teamBBase : this._mapData.teamABase; + if (enemyBase && enemyBase.length > 0) { + tank.setTargetBase({ + x: MAP_OFFSET_X + enemyBase[0].col * TILE_SIZE + TILE_SIZE / 2, + y: MAP_OFFSET_Y + enemyBase[0].row * TILE_SIZE + TILE_SIZE / 2, + }); + } + } else { + // Use PlayerTank for human players + tank = new PlayerTank({ + col: spawn.col, + row: spawn.row, + }); + tank.color = tankColor; + // Unlimited lives for 3v3 + tank.lives = 999; + } + + tank.activateShield(3000); + + // Set initial direction based on team + if (team === 'A') { + tank.direction = DIRECTION.RIGHT; + } else { + tank.direction = DIRECTION.LEFT; + } + + // Clear spawn area + this._clearSpawnArea(spawn.col, spawn.row); + + const playerData = { + playerId: member.playerId, + tank, + isBot, + team, + isLocal, + spawnPoint: spawn, + respawnTimer: 0, + isRespawning: false, + }; + + this._players.push(playerData); + + // Initialize remote target + if (!isLocal) { + this._remoteTargets[member.playerId] = { + x: tank.x, + y: tank.y, + direction: tank.direction, + }; + } + } + }, + + exit() { + this._initialized = false; + this._cleanupNetworkEvents(); + this._bullets = []; + this._explosions = []; + this._players = []; + }, + + _setupNetworkEvents() { + this._cleanupNetworkEvents(); + const nm = this._networkManager; + if (!nm) return; + + const unsubs = []; + + // Receive player state sync from other players + unsubs.push(nm.on(NET_MSG.PLAYER_STATE, (data) => { + if (data.playerId && data.playerId !== this._myPlayerId) { + this._updateRemotePlayerState(data); + } + })); + + // Receive player input from other players + unsubs.push(nm.on(NET_MSG.PLAYER_INPUT, (data) => { + if (data.playerId && data.playerId !== this._myPlayerId) { + const player = this._players.find(p => p.playerId === data.playerId); + if (player) { + player._remoteInput = { + direction: data.direction !== undefined ? data.direction : -1, + moving: data.moving || false, + }; + } + } + })); + + // Receive bullet fire from other players + unsubs.push(nm.on(NET_MSG.BULLET_FIRE, (data) => { + if (data.playerId && data.playerId !== this._myPlayerId) { + this._spawnRemoteBullet(data); + } + })); + + // Receive player killed notification + unsubs.push(nm.on(NET_MSG.PLAYER_KILLED, (data) => { + if (data.victimId) { + const victim = this._players.find(p => p.playerId === data.victimId); + if (victim && victim.tank.alive) { + victim.tank.alive = false; + this._spawnExplosion(victim.tank.x, victim.tank.y, true); + this._startRespawn(victim); + } + // Update stats + if (data.killerId && this._stats[data.killerId]) { + this._stats[data.killerId].kills++; + } + if (this._stats[data.victimId]) { + this._stats[data.victimId].deaths++; + } + } + })); + + // Receive player respawn + unsubs.push(nm.on(NET_MSG.PLAYER_RESPAWN, (data) => { + if (data.playerId && data.playerId !== this._myPlayerId) { + const player = this._players.find(p => p.playerId === data.playerId); + if (player) { + this._respawnPlayer(player); + } + } + })); + + // Receive base hit + unsubs.push(nm.on(NET_MSG.BASE_HIT, (data) => { + if (data.teamABaseHp !== undefined) this._teamABaseHp = data.teamABaseHp; + if (data.teamBBaseHp !== undefined) this._teamBBaseHp = data.teamBBaseHp; + + // Check if base destroyed (immediate client-side feedback) + if (!this._gameOver) { + if (this._teamABaseHp <= 0) { + this._winner = 'B'; + this._winReason = 'base_destroyed'; + this._gameOver = true; + GameGlobal.audioManager.playSFX(this._myTeam === 'B' ? 'victory' : 'gameover'); + } else if (this._teamBBaseHp <= 0) { + this._winner = 'A'; + this._winReason = 'base_destroyed'; + this._gameOver = true; + GameGlobal.audioManager.playSFX(this._myTeam === 'A' ? 'victory' : 'gameover'); + } + } + })); + + // Receive base destroyed / game over + unsubs.push(nm.on(NET_MSG.BASE_DESTROYED, (data) => { + if (data.team === 'A') { + this._teamABaseHp = 0; + this._winner = 'B'; + } else { + this._teamBBaseHp = 0; + this._winner = 'A'; + } + this._winReason = 'base_destroyed'; + this._gameOver = true; + })); + + unsubs.push(nm.on(NET_MSG.TEAM_GAME_OVER, (data) => { + this._winner = data.winner || ''; + this._winReason = data.reason || 'base_destroyed'; + this._teamABaseHp = data.teamABaseHp; + this._teamBBaseHp = data.teamBBaseHp; + this._gameOver = true; + })); + + // Receive game over (1v1 mode uses GAME_OVER instead of TEAM_GAME_OVER) + unsubs.push(nm.on(NET_MSG.GAME_OVER, (data) => { + this._winner = data.winner || ''; + this._winReason = data.reason || 'base_destroyed'; + if (data.teamABaseHp !== undefined) this._teamABaseHp = data.teamABaseHp; + if (data.teamBBaseHp !== undefined) this._teamBBaseHp = data.teamBBaseHp; + this._gameOver = true; + })); + + // Opponent left (1v1 mode) + unsubs.push(nm.on(NET_MSG.OPPONENT_LEFT, () => { + if (!this._gameOver) { + this._winner = this._myTeam; + this._winReason = 'disconnected'; + this._gameOver = true; + } + })); + + // Player disconnect + unsubs.push(nm.on(NET_MSG.PLAYER_DISCONNECT, (data) => { + console.log(`[TeamGameScene] Player disconnected: ${data.playerId}`); + })); + + // Bot takeover + unsubs.push(nm.on(NET_MSG.BOT_TAKEOVER, (data) => { + const player = this._players.find(p => p.playerId === data.playerId); + if (player) { + player.isBot = true; + console.log(`[TeamGameScene] Bot takeover: ${data.playerId}`); + } + })); + + unsubs.push(nm.on(NET_MSG.RECONNECT_OK, (data) => { + console.log('[TeamGameScene] Reconnected successfully'); + this._isDisconnected = false; + this._reconnectAttempts = 0; + // Restore game state from server + if (data.teamABaseHp !== undefined) this._teamABaseHp = data.teamABaseHp; + if (data.teamBBaseHp !== undefined) this._teamBBaseHp = data.teamBBaseHp; + })); + + unsubs.push(nm.on('disconnected', () => { + if (!this._gameOver) { + this._isDisconnected = true; + this._reconnectTimer = 0; + this._reconnectAttempts = 0; + console.log('[TeamGameScene] Disconnected, attempting reconnect...'); + } + })); + + this._unsubscribers = unsubs; + }, + + _cleanupNetworkEvents() { + for (const unsub of this._unsubscribers) { + if (typeof unsub === 'function') unsub(); + } + this._unsubscribers = []; + }, + + _updateRemotePlayerState(data) { + const target = this._remoteTargets[data.playerId]; + if (target) { + // Convert normalized grid coords back to local pixel coords + if (data.col !== undefined && data.row !== undefined) { + target.x = MAP_OFFSET_X + data.col * TILE_SIZE; + target.y = MAP_OFFSET_Y + data.row * TILE_SIZE; + } else { + target.x = data.x; + target.y = data.y; + } + target.direction = data.direction; + } + const player = this._players.find(p => p.playerId === data.playerId); + if (player) { + if (data.hp !== undefined) player.tank.hp = data.hp; + if (data.alive !== undefined) player.tank.alive = data.alive; + } + }, + + // ============================================================ + // Update + // ============================================================ + update(dt) { + if (!this._initialized || this._paused) return; + + // Handle reconnection + if (this._isDisconnected) { + this._reconnectTimer += dt; + if (this._reconnectTimer >= this._reconnectInterval) { + this._reconnectTimer = 0; + this._attemptReconnect(); + } + return; // Pause game updates while disconnected + } + + // Game over delay + if (this._gameOver) { + this._gameOverDelay += dt; + this._updateExplosions(dt); + if (this._gameOverDelay >= this._gameOverDelayDuration) { + this._transitionToResult(); + } + return; + } + + // Elapsed time (count up, for display only) + this._elapsedTime += dt; + + // Update map + this._mapManager.update(dt); + + // Update all players + for (const player of this._players) { + if (player.isRespawning) { + player.respawnTimer -= dt * 1000; + if (player.respawnTimer <= 0 && (player.isLocal || player.isBot)) { + this._respawnPlayer(player); + // Notify server + if (this._networkManager) { + this._networkManager.send(NET_MSG.PLAYER_RESPAWN, { + playerId: player.playerId, + }); + } + } + continue; + } + + if (!player.tank.alive) continue; + + if (player.isLocal) { + // Local player movement + if (this._joystick.active && this._joystick.direction >= 0) { + player.tank.move(this._joystick.direction, dt, this._mapManager); + } + } else if (!player.isBot) { + // Remote player interpolation + this._interpolateRemoteTank(player, dt); + } else { + // Bot AI using BotTank.updateAI + this._updateBotAI(player, dt); + } + + player.tank.update(dt); + } + + // Update bullets + for (const bullet of this._bullets) { + bullet.update(dt); + } + + // Collision detection + this._checkCollisions(); + + // Update explosions + this._updateExplosions(dt); + + // Cleanup + this._cleanup(); + + // Send local state periodically + this._syncTimer += dt; + if (this._syncTimer >= this._syncInterval) { + this._syncTimer = 0; + this._sendLocalState(); + } + + // Send input changes + this._sendInputIfChanged(); + }, + + _interpolateRemoteTank(player, dt) { + const target = this._remoteTargets[player.playerId]; + if (!target) return; + + const tank = player.tank; + tank.direction = target.direction; + + const dx = target.x - tank.x; + const dy = target.y - tank.y; + const dist = Math.abs(dx) + Math.abs(dy); + + if (dist > TILE_SIZE * 3) { + // Too far away (e.g. respawn / teleport) — snap directly + tank.x = target.x; + tank.y = target.y; + } else if (dist > 0.5) { + // Interpolate towards target position. + // No local terrain collision check — the remote position is authoritative + // from the opponent's client. Local terrain may differ (bullets destroy + // bricks independently on each client), so blocking here would cause + // the remote tank to get stuck at the wrong position. + // No input prediction — it causes accumulated drift because Tank.move() + // has grid-snapping and terrain-sliding that differ between clients. + const lerpSpeed = Math.min(20 * dt, 1); // cap at 1 to avoid overshoot + + tank.x += dx * lerpSpeed; + tank.y += dy * lerpSpeed; + } else { + // Close enough — snap to target + tank.x = target.x; + tank.y = target.y; + } + }, + + _updateBotAI(player, dt) { + const tank = player.tank; + + // Use BotTank's built-in AI if available + if (typeof tank.updateAI === 'function') { + tank.updateAI(dt, this._mapManager, (bot) => { + this._botFire(player); + }); + return; + } + + // Fallback: simple bot AI for non-BotTank instances + if (!player._botTimer) player._botTimer = 0; + if (!player._botShootTimer) player._botShootTimer = 0; + if (!player._botDirection) player._botDirection = Math.floor(Math.random() * 4); + + player._botTimer += dt; + player._botShootTimer += dt; + + // Change direction every 1-3 seconds + if (player._botTimer > 1 + Math.random() * 2) { + player._botTimer = 0; + player._botDirection = Math.floor(Math.random() * 4); + } + + // Move + tank.move(player._botDirection, dt, this._mapManager); + + // Shoot every 1-2 seconds + if (player._botShootTimer > 1 + Math.random()) { + player._botShootTimer = 0; + this._botFire(player); + } + }, + + _sendLocalState() { + if (!this._networkManager || !this._localPlayer) return; + const tank = this._localPlayer.tank; + // Send normalized grid coordinates instead of pixel coordinates + // so that different screen sizes produce the same grid position. + this._networkManager.send(NET_MSG.PLAYER_STATE, { + playerId: this._myPlayerId, + col: (tank.x - MAP_OFFSET_X) / TILE_SIZE, + row: (tank.y - MAP_OFFSET_Y) / TILE_SIZE, + direction: tank.direction, + hp: tank.hp, + alive: tank.alive, + }); + }, + + _sendInputIfChanged() { + if (!this._networkManager) return; + + const currentInput = { + direction: this._joystick.direction, + moving: this._joystick.active && this._joystick.direction >= 0, + }; + + if ( + !this._lastSentInput || + this._lastSentInput.direction !== currentInput.direction || + this._lastSentInput.moving !== currentInput.moving + ) { + this._lastSentInput = { ...currentInput }; + this._networkManager.send(NET_MSG.PLAYER_INPUT, { + playerId: this._myPlayerId, + ...currentInput, + }); + } + }, + + // ============================================================ + // Collision Detection + // ============================================================ + _checkCollisions() { + const aliveBullets = this._bullets.filter(b => b.alive); + + // Bullet vs terrain (including bases) + for (const bullet of aliveBullets) { + if (!bullet.alive) continue; + this._checkBulletTerrain(bullet); + } + + // Bullet vs tanks (team-aware) + for (const bullet of aliveBullets) { + if (!bullet.alive) continue; + this._checkBulletVsTanks(bullet); + } + + // Bullet vs bullet + this._checkBulletVsBullet(aliveBullets); + + // Tank vs tank (push apart) + const alivePlayers = this._players.filter(p => p.tank.alive && !p.isRespawning); + for (let i = 0; i < alivePlayers.length; i++) { + for (let j = i + 1; j < alivePlayers.length; j++) { + if (alivePlayers[i].tank.collidesWith(alivePlayers[j].tank)) { + this._separateTanks(alivePlayers[i].tank, alivePlayers[j].tank); + } + } + } + }, + + _checkBulletTerrain(bullet) { + const { row, col } = this._mapManager.pixelToGrid(bullet.x, bullet.y); + + if (row < 0 || row >= this._mapData.grid.length || col < 0 || col >= this._mapData.grid[0].length) { + bullet.destroy(); + this._spawnExplosion(bullet.x, bullet.y, false); + return; + } + + const terrain = this._mapManager.getTerrain(row, col); + + if (terrain === TERRAIN.BRICK) { + this._mapManager.setTerrain(row, col, TERRAIN.EMPTY); + bullet.destroy(); + this._spawnExplosion(bullet.x, bullet.y, false); + } else if (terrain === TERRAIN.BASE_WALL) { + // Determine which team this base wall belongs to + const wallTeam = this._getBaseWallTeam(row, col); + const bulletOwner = this._players.find(p => p.playerId === bullet.ownerPlayerId); + + // Friendly-fire immunity: own team's bullets don't damage own base walls + if (bulletOwner && wallTeam && bulletOwner.team === wallTeam) { + bullet.destroy(); + return; + } + + // Base wall has HP — use bulletHitTerrain for proper HP tracking + this._mapManager.bulletHitTerrain(row, col, bullet.canBreakSteel); + bullet.destroy(); + this._spawnExplosion(bullet.x, bullet.y, false); + } else if (terrain === TERRAIN.STEEL) { + if (bullet.canBreakSteel) { + this._mapManager.setTerrain(row, col, TERRAIN.EMPTY); + } + bullet.destroy(); + this._spawnExplosion(bullet.x, bullet.y, false); + } else if (terrain === TERRAIN.BASE) { + // Base hit! Determine which team's base + bullet.destroy(); + this._spawnExplosion(bullet.x, bullet.y, true); + this._handleBaseHit(bullet, row, col); + } + }, + + /** + * Determine which team a BASE_WALL tile belongs to based on proximity to team bases. + * @param {number} row + * @param {number} col + * @returns {string} 'A', 'B', or '' if unknown + */ + _getBaseWallTeam(row, col) { + // Check proximity to each team's base positions + let minDistA = Infinity; + let minDistB = Infinity; + + if (this._mapData.teamABase) { + for (const base of this._mapData.teamABase) { + const dist = Math.abs(row - base.row) + Math.abs(col - base.col); + if (dist < minDistA) minDistA = dist; + } + } + if (this._mapData.teamBBase) { + for (const base of this._mapData.teamBBase) { + const dist = Math.abs(row - base.row) + Math.abs(col - base.col); + if (dist < minDistB) minDistB = dist; + } + } + + if (minDistA < minDistB) return 'A'; + if (minDistB < minDistA) return 'B'; + // Fallback: use column position (left = A, right = B) + return col < this._mapData.grid[0].length / 2 ? 'A' : 'B'; + }, + + _handleBaseHit(bullet, row, col) { + // Determine which team's base was hit based on position + const bulletOwner = this._players.find(p => p.playerId === bullet.ownerPlayerId); + if (!bulletOwner) return; + + // Check if the base belongs to team A or team B + let targetTeam = ''; + if (this._mapData.teamABase) { + for (const base of this._mapData.teamABase) { + if (base.row === row && base.col === col) { + targetTeam = 'A'; + break; + } + } + } + if (!targetTeam && this._mapData.teamBBase) { + for (const base of this._mapData.teamBBase) { + if (base.row === row && base.col === col) { + targetTeam = 'B'; + break; + } + } + } + + // If we can't determine by exact position, use column position + if (!targetTeam) { + targetTeam = col < this._mapData.grid[0].length / 2 ? 'A' : 'B'; + } + + // Friendly-fire immunity: bullets do not damage their own team's base + if (bulletOwner.team === targetTeam) { + return; // ignore friendly base hit + } + + // Local player or local bot reports base hits to server + if ((bulletOwner.isLocal || bulletOwner.isBot) && this._networkManager) { + this._networkManager.send(NET_MSG.BASE_HIT, { + targetTeam, + damage: 1, + attackerId: bulletOwner.playerId, + }); + + // Update local stats + if (this._stats[bulletOwner.playerId]) { + this._stats[bulletOwner.playerId].baseDamage++; + } + } + }, + + _checkBulletVsTanks(bullet) { + const bb = bullet.getBounds(); + const bulletOwner = this._players.find(p => p.playerId === bullet.ownerPlayerId); + if (!bulletOwner) return; + + for (const player of this._players) { + if (!player.tank.alive || player.isRespawning) continue; + + // Friendly fire protection: don't hit teammates + if (player.team === bulletOwner.team) continue; + + const tb = player.tank.getBounds(); + if (this._rectsOverlap(bb, tb)) { + const destroyed = player.tank.takeDamage(1); + bullet.destroy(); + + if (destroyed) { + this._spawnExplosion(player.tank.x, player.tank.y, true); + this._handlePlayerDeath(player, bulletOwner); + } else { + this._spawnExplosion(bullet.x, bullet.y, false); + } + return; + } + } + }, + + _checkBulletVsBullet(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 bullets from different teams + const ownerI = this._players.find(p => p.playerId === bullets[i].ownerPlayerId); + const ownerJ = this._players.find(p => p.playerId === bullets[j].ownerPlayerId); + if (ownerI && ownerJ && ownerI.team === ownerJ.team) 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._spawnExplosion(mx, my, false); + } + } + } + }, + + _separateTanks(tankA, tankB) { + const a = tankA.getBounds(); + const b = tankB.getBounds(); + 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; + + // Save original positions in case push causes terrain collision + const origAX = tankA.x, origAY = tankA.y; + const origBX = tankB.x, origBY = tankB.y; + + if (overlapX < overlapY) { + const sign = tankA.x < tankB.x ? -1 : 1; + const push = overlapX / 2; + tankA.x += sign * push; + tankB.x -= sign * push; + } else { + const sign = tankA.y < tankB.y ? -1 : 1; + const push = overlapY / 2; + tankA.y += sign * push; + tankB.y -= sign * push; + } + + // Validate pushed positions against terrain; revert if stuck in wall + if (this._mapManager) { + const leftA = tankA.x - tankA.halfSize; + const topA = tankA.y - tankA.halfSize; + if (this._mapManager.rectCollidesWithTerrain(leftA, topA, tankA.size, tankA.size)) { + tankA.x = origAX; + tankA.y = origAY; + } + const leftB = tankB.x - tankB.halfSize; + const topB = tankB.y - tankB.halfSize; + if (this._mapManager.rectCollidesWithTerrain(leftB, topB, tankB.size, tankB.size)) { + tankB.x = origBX; + tankB.y = origBY; + } + } + }, + + _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 + ); + }, + + // ============================================================ + // Game Logic + // ============================================================ + _localFire() { + if (!this._localPlayer || !this._localPlayer.tank.alive) return; + if (!this._localPlayer.tank.canFire()) return; + if (this._gameOver || this._paused) return; + + const tank = this._localPlayer.tank; + 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: 'local', + canBreakSteel: tank.canBreakSteel(), + ownerTank: tank, + }); + bullet.ownerPlayerId = this._myPlayerId; + tank.activeBullets++; + this._bullets.push(bullet); + GameGlobal.audioManager.playSFX('shoot'); + + // Send to network — use normalized grid coordinates + if (this._networkManager) { + this._networkManager.send(NET_MSG.BULLET_FIRE, { + playerId: this._myPlayerId, + col: (bullet.x - MAP_OFFSET_X) / TILE_SIZE, + row: (bullet.y - MAP_OFFSET_Y) / TILE_SIZE, + direction: bullet.direction, + canBreakSteel: bullet.canBreakSteel, + }); + } + }, + + _botFire(player) { + if (!player.tank.alive || !player.tank.canFire()) return; + + const tank = player.tank; + 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: 'bot', + canBreakSteel: false, + ownerTank: tank, + }); + bullet.ownerPlayerId = player.playerId; + tank.activeBullets++; + this._bullets.push(bullet); + }, + + _spawnRemoteBullet(data) { + const player = this._players.find(p => p.playerId === data.playerId); + if (!player) return; + + // Convert normalized grid coords back to local pixel coords + let bx, by; + if (data.col !== undefined && data.row !== undefined) { + bx = MAP_OFFSET_X + data.col * TILE_SIZE; + by = MAP_OFFSET_Y + data.row * TILE_SIZE; + } else { + bx = data.x; + by = data.y; + } + + const bullet = this._bulletPool.get(); + bullet.init({ + x: bx, + y: by, + direction: data.direction, + owner: 'remote', + canBreakSteel: data.canBreakSteel || false, + ownerTank: player.tank, + }); + bullet.ownerPlayerId = data.playerId; + player.tank.activeBullets++; + this._bullets.push(bullet); + }, + + _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'); + }, + + _clearSpawnArea(col, row) { + // Clear a 3x3 area around the spawn point to ensure the tank + // (whose size is ~0.85 tiles) won't overlap adjacent blocking tiles + for (let dr = -1; dr <= 1; dr++) { + for (let dc = -1; dc <= 1; dc++) { + const r = row + dr; + const c = col + dc; + const terrain = this._mapManager.getTerrain(r, c); + // Clear any tank-blocking terrain except BASE and RIVER + if (terrain === TERRAIN.BRICK || terrain === TERRAIN.STEEL || terrain === TERRAIN.BASE_WALL) { + this._mapManager.setTerrain(r, c, TERRAIN.EMPTY); + } + } + } + }, + + _handlePlayerDeath(victim, killer) { + // Update stats + if (this._stats[killer.playerId]) { + this._stats[killer.playerId].kills++; + } + if (this._stats[victim.playerId]) { + this._stats[victim.playerId].deaths++; + } + + // Start respawn timer + this._startRespawn(victim); + + // If local player or local bot killed someone, notify server + if ((killer.isLocal || killer.isBot) && this._networkManager) { + this._networkManager.send(NET_MSG.PLAYER_KILLED, { + killerId: killer.playerId, + victimId: victim.playerId, + }); + } + + // Play sound + if (victim.isLocal) { + GameGlobal.audioManager.playSFX('gameover'); + } + }, + + _startRespawn(player) { + player.isRespawning = true; + player.respawnTimer = this._battleConfig ? this._battleConfig.respawnDelay : TEAM_RESPAWN_DELAY; + player.tank.alive = false; + }, + + _respawnPlayer(player) { + player.isRespawning = false; + player.respawnTimer = 0; + player.tank.alive = true; + player.tank.hp = 1; + + // Respawn at team spawn point + const spawn = player.spawnPoint; + // Clear spawn area to prevent getting stuck in rebuilt terrain + this._clearSpawnArea(spawn.col, spawn.row); + player.tank.x = MAP_OFFSET_X + spawn.col * TILE_SIZE + TILE_SIZE / 2; + player.tank.y = MAP_OFFSET_Y + spawn.row * TILE_SIZE + TILE_SIZE / 2; + player.tank.activateShield(3000); + + // Update remote target + if (!player.isLocal) { + this._remoteTargets[player.playerId] = { + x: player.tank.x, + y: player.tank.y, + direction: player.tank.direction, + }; + } + }, + + _updateExplosions(dt) { + for (const exp of this._explosions) { + exp.update(dt); + } + }, + + _cleanup() { + 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); + } + } + 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); + } + } + }, + + _transitionToResult() { + const sm = GameGlobal.sceneManager; + if (!sm._scenes.has(SCENE.TEAM_RESULT)) { + const TeamResultScene = require('./TeamResultScene'); + sm.register(SCENE.TEAM_RESULT, TeamResultScene); + } + + const didWin = this._winner === this._myTeam; + + sm.switchTo(SCENE.TEAM_RESULT, { + winner: this._winner, + winReason: this._winReason, + myTeam: this._myTeam, + didWin, + isDraw: false, // No draw in base-destruction mode + teamABaseHp: this._teamABaseHp, + teamBBaseHp: this._teamBBaseHp, + stats: this._stats, + players: this._players.map(p => ({ + playerId: p.playerId, + team: p.team, + isBot: p.isBot, + isLocal: p.isLocal, + })), + elapsedTime: Math.floor(this._elapsedTime), + teamId: this._teamId, + battleMode: this._battleMode, + }); + }, + + // ============================================================ + // Render + // ============================================================ + render(ctx) { + if (!this._initialized) return; + + // Game area background + ctx.fillStyle = '#111111'; + ctx.fillRect(MAP_OFFSET_X, MAP_OFFSET_Y, MAP_WIDTH, MAP_HEIGHT); + + // Render map + this._mapManager.render(ctx); + + // Render all tanks + for (const player of this._players) { + if (player.tank.alive && !player.isRespawning) { + player.tank.render(ctx); + + // Draw team indicator above tank + if (!player.isLocal) { + const tx = player.tank.x; + const ty = player.tank.y - player.tank.halfSize - 8; + ctx.fillStyle = player.team === this._myTeam ? TEAM_A_COLOR : TEAM_B_COLOR; + ctx.font = 'bold 8px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const label = player.isBot ? '🤖' : (player.team === this._myTeam ? '▲' : '▼'); + ctx.fillText(label, tx, ty); + } + } + } + + // Render forest overlay + 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); + + // Respawn overlay + if (this._localPlayer && this._localPlayer.isRespawning) { + this._renderRespawnOverlay(ctx); + } + + // Pause overlay + if (this._paused) { + this._renderPauseOverlay(ctx); + } + + // Game over overlay + if (this._gameOver) { + this._renderGameOverOverlay(ctx); + } + + // Disconnection overlay + if (this._isDisconnected) { + this._renderDisconnectOverlay(ctx); + } + }, + + _getTeamStats(team) { + let kills = 0, deaths = 0; + for (const p of this._players) { + if (p.team === team && this._stats[p.playerId]) { + kills += this._stats[p.playerId].kills; + deaths += this._stats[p.playerId].deaths; + } + } + return { kills, deaths }; + }, + + _renderHUD(ctx) { + const hudY = 4; + + // Team A base HP (left) + const barWidth = 80; + const barHeight = 12; + const barY = hudY + 2; + const timeGap = 30; // half-width reserved for the timer in the center + + // Team A label + HP bar + const teamALabel = this._battleMode === '1v1' ? 'P1' : t('team.teamA'); + const teamBLabel = this._battleMode === '1v1' ? 'P2' : t('team.teamB'); + ctx.fillStyle = this._battleMode === '1v1' ? PLAYER1_COLOR : TEAM_A_COLOR; + ctx.font = 'bold 10px Arial'; + ctx.textAlign = 'right'; + ctx.textBaseline = 'top'; + ctx.fillText(teamALabel, SCREEN_WIDTH / 2 - timeGap - barWidth - 5, barY); + + // Team A HP bar background + const barAX = SCREEN_WIDTH / 2 - timeGap; + ctx.fillStyle = '#333333'; + ctx.fillRect(barAX, barY, -barWidth, barHeight); + + // Team A HP bar fill + const maxBaseHp = this._battleConfig ? this._battleConfig.baseHp : TEAM_BASE_HP; + const hpRatioA = this._teamABaseHp / maxBaseHp; + const teamADisplayColor = this._battleMode === '1v1' ? PLAYER1_COLOR : TEAM_A_COLOR; + const teamBDisplayColor = this._battleMode === '1v1' ? PLAYER2_COLOR : TEAM_B_COLOR; + ctx.fillStyle = hpRatioA > 0.3 ? teamADisplayColor : '#FF4444'; + ctx.fillRect(barAX, barY, -barWidth * hpRatioA, barHeight); + + // Team A HP text + ctx.fillStyle = '#FFFFFF'; + ctx.font = 'bold 9px Arial'; + ctx.textAlign = 'center'; + ctx.fillText(`${this._teamABaseHp}`, barAX - barWidth / 2, barY + barHeight / 2 + 1); + + // Elapsed time (center, count up) + const minutes = Math.floor(this._elapsedTime / 60); + const seconds = Math.floor(this._elapsedTime % 60); + const timeStr = `${minutes}:${seconds.toString().padStart(2, '0')}`; + + ctx.fillStyle = '#FFFFFF'; + ctx.font = 'bold 16px Arial'; + ctx.textAlign = 'center'; + ctx.fillText(timeStr, SCREEN_WIDTH / 2, hudY); + + // Team B label + HP bar + const barBStart = SCREEN_WIDTH / 2 + timeGap; + ctx.fillStyle = this._battleMode === '1v1' ? PLAYER2_COLOR : TEAM_B_COLOR; + ctx.font = 'bold 10px Arial'; + ctx.textAlign = 'left'; + ctx.fillText(teamBLabel, barBStart + barWidth + 5, barY); + + // Team B HP bar background + ctx.fillStyle = '#333333'; + ctx.fillRect(barBStart, barY, barWidth, barHeight); + + // Team B HP bar fill + const hpRatioB = this._teamBBaseHp / maxBaseHp; + ctx.fillStyle = hpRatioB > 0.3 ? teamBDisplayColor : '#FF4444'; + ctx.fillRect(barBStart, barY, barWidth * hpRatioB, barHeight); + + // Team B HP text + ctx.fillStyle = '#FFFFFF'; + ctx.font = 'bold 9px Arial'; + ctx.textAlign = 'center'; + ctx.fillText(`${this._teamBBaseHp}`, barBStart + barWidth / 2, barY + barHeight / 2 + 1); + + // My team indicator + if (this._battleMode === '1v1') { + const mySlot = this._myTeam === 'A' ? 1 : 2; + ctx.fillStyle = this._myTeam === 'A' ? PLAYER1_COLOR : PLAYER2_COLOR; + ctx.font = '9px Arial'; + ctx.textAlign = 'center'; + ctx.fillText(t('pvp.playerLabel', { slot: mySlot }), SCREEN_WIDTH / 2, hudY + 20); + } else { + ctx.fillStyle = this._myTeam === 'A' ? TEAM_A_COLOR : TEAM_B_COLOR; + ctx.font = '9px Arial'; + ctx.textAlign = 'center'; + ctx.fillText(t('team.myTeam', { team: this._myTeam }), SCREEN_WIDTH / 2, hudY + 20); + } + + // Team kill/death totals + const myTeamStats = this._getTeamStats(this._myTeam); + const enemyTeam = this._myTeam === 'A' ? 'B' : 'A'; + const enemyTeamStats = this._getTeamStats(enemyTeam); + + ctx.fillStyle = '#AAAAAA'; + ctx.font = '10px Arial'; + ctx.textAlign = 'left'; + ctx.fillText(t('team.killDeath', { kills: myTeamStats.kills, deaths: myTeamStats.deaths }), 10, hudY); + + ctx.textAlign = 'right'; + ctx.fillText(t('team.killDeath', { kills: enemyTeamStats.kills, deaths: enemyTeamStats.deaths }), SCREEN_WIDTH - 10, hudY); + + // Latency + if (this._networkManager) { + ctx.fillStyle = '#666666'; + ctx.font = '9px Arial'; + ctx.textAlign = 'right'; + ctx.fillText(`${this._networkManager.latency || 0}ms`, SCREEN_WIDTH - 10, hudY + 14); + } + }, + + _renderRespawnOverlay(ctx) { + const remaining = Math.ceil(this._localPlayer.respawnTimer / 1000); + ctx.fillStyle = 'rgba(0,0,0,0.3)'; + ctx.fillRect(SCREEN_WIDTH / 2 - 60, SCREEN_HEIGHT / 2 - 25, 120, 50); + + ctx.fillStyle = '#FFFFFF'; + ctx.font = 'bold 14px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(t('team.respawn', { seconds: remaining }), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2); + }, + + _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); + }, + + _renderGameOverOverlay(ctx) { + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); + + let text, color; + if (this._winner === this._myTeam) { + text = t('team.victory'); + color = '#00FF00'; + } else { + text = t('team.defeat'); + color = '#FF0000'; + } + + ctx.fillStyle = color; + ctx.font = 'bold 36px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 10); + + // Base HP summary + ctx.fillStyle = '#FFFFFF'; + ctx.font = '14px Arial'; + if (this._battleMode === '1v1') { + ctx.fillText( + t('pvp.baseHpSummary', { hp1: this._teamABaseHp, hp2: this._teamBBaseHp }), + SCREEN_WIDTH / 2, + SCREEN_HEIGHT / 2 + 25 + ); + } else { + ctx.fillText( + t('team.baseHpSummary', { hpA: this._teamABaseHp, hpB: this._teamBBaseHp }), + SCREEN_WIDTH / 2, + SCREEN_HEIGHT / 2 + 25 + ); + } + }, + + _renderDisconnectOverlay(ctx) { + ctx.fillStyle = 'rgba(0,0,0,0.7)'; + ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); + + ctx.fillStyle = '#FF6347'; + ctx.font = 'bold 20px Arial'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(t('team.disconnectTitle'), SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2 - 30); + + const dots = '.'.repeat(Math.floor(this._reconnectTimer * 3) % 4); + ctx.fillStyle = '#FFFFFF'; + ctx.font = '14px Arial'; + ctx.fillText( + t('team.reconnecting', { dots, attempts: this._reconnectAttempts, max: this._maxReconnectAttempts }), + SCREEN_WIDTH / 2, + SCREEN_HEIGHT / 2 + 5 + ); + + ctx.fillStyle = '#AAAAAA'; + ctx.font = '12px Arial'; + ctx.fillText( + t('team.reconnectHint'), + SCREEN_WIDTH / 2, + SCREEN_HEIGHT / 2 + 30 + ); + }, + + async _attemptReconnect() { + this._reconnectAttempts++; + console.log(`[TeamGameScene] Reconnect attempt ${this._reconnectAttempts}/${this._maxReconnectAttempts}`); + + if (this._reconnectAttempts > this._maxReconnectAttempts) { + // Give up reconnecting — treat as defeat + this._isDisconnected = false; + this._winner = this._myTeam === 'A' ? 'B' : 'A'; + this._winReason = 'disconnected'; + this._gameOver = true; + return; + } + + const nm = this._networkManager; + if (!nm) return; + + try { + // Try to reconnect to server + const { SERVER_URL } = require('../base/GameGlobal'); + const ok = await nm.connect(SERVER_URL); + if (ok) { + // Send reconnect message + nm.send(NET_MSG.RECONNECT, { + teamId: this._teamId, + playerId: this._myPlayerId, + }); + } + } catch (e) { + console.error('[TeamGameScene] Reconnect failed:', e); + } + }, + + // ============================================================ + // Touch Handling + // ============================================================ + handleTouch(eventType, e) { + if (this._gameOver) return; + + if (this._paused) { + if (eventType === 'touchstart') { + this._paused = false; + } + return; + } + + const touches = eventType === 'touchend' ? e.changedTouches : e.touches; + for (let i = 0; i < touches.length; i++) { + const touch = touches[i]; + if (this._joystick.handleTouch(eventType, touch)) continue; + if (this._fireButton.handleTouch(eventType, touch)) continue; + + // Pause button area + if (eventType === 'touchstart') { + if (touch.clientX > SCREEN_WIDTH - 50 && touch.clientY < MAP_OFFSET_Y) { + this._paused = true; + } + } + } + }, +}; + +module.exports = TeamGameScene; diff --git a/js/scenes/TeamResultScene.js b/js/scenes/TeamResultScene.js new file mode 100644 index 0000000..77f0f4c --- /dev/null +++ b/js/scenes/TeamResultScene.js @@ -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; diff --git a/js/scenes/TeamRoomScene.js b/js/scenes/TeamRoomScene.js new file mode 100644 index 0000000..055a8c4 --- /dev/null +++ b/js/scenes/TeamRoomScene.js @@ -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; diff --git a/js/ui/FireButton.js b/js/ui/FireButton.js new file mode 100644 index 0000000..d79129d --- /dev/null +++ b/js/ui/FireButton.js @@ -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; diff --git a/js/ui/Joystick.js b/js/ui/Joystick.js new file mode 100644 index 0000000..5ce2f45 --- /dev/null +++ b/js/ui/Joystick.js @@ -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; diff --git a/js/ui/TutorialOverlay.js b/js/ui/TutorialOverlay.js new file mode 100644 index 0000000..19212b6 --- /dev/null +++ b/js/ui/TutorialOverlay.js @@ -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; diff --git a/project.config.json b/project.config.json new file mode 100644 index 0000000..f2ed52f --- /dev/null +++ b/project.config.json @@ -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": {} +} \ No newline at end of file diff --git a/project.private.config.json b/project.private.config.json new file mode 100644 index 0000000..1e79040 --- /dev/null +++ b/project.private.config.json @@ -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 + } +} \ No newline at end of file diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..05ce11d --- /dev/null +++ b/server/index.js @@ -0,0 +1,1607 @@ +/** + * Tank War PVP Server + * WebSocket server for online 1v1 multiplayer. + * Handles room management, message relay, and basic game state authority. + */ + +const { WebSocketServer } = require('ws'); + +// ============================================================ +// Configuration +// ============================================================ +const PORT = process.env.PORT || 3000; +const HOST = process.env.HOST || '0.0.0.0'; +const HEARTBEAT_INTERVAL = 10000; // ms +const ROOM_TIMEOUT = 300000; // 5 minutes room idle timeout + +// ============================================================ +// Message Types (must match client NET_MSG) +// ============================================================ +const NET_MSG = { + 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', + 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', + 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', +}; + +// ============================================================ +// 3v3 Configuration +// ============================================================ +const TEAM_SIZE = 3; +const TEAM_MATCH_TIMEOUT = 6000; // 6s matchmaking timeout (testing) +const TEAM_RECONNECT_TIMEOUT = 60000; // 60s to reconnect + +// ============================================================ +// Battle Configuration (X vs X) +// ============================================================ +const BATTLE_CONFIG = { + '1v1': { teamSize: 1, baseHp: 5, fillWithBots: false }, + '3v3': { teamSize: 3, baseHp: 10, fillWithBots: true }, +}; + +// ============================================================ +// Room Management +// ============================================================ +/** @type {Map} */ +const rooms = new Map(); + +/** @type {Map} */ +const players = new Map(); + +/** @type {Map} */ +const teamRooms = new Map(); + +/** @type {Array} Matching pool for team queues */ +const teamMatchPool = []; + +/** @type {Array} Matching pool for solo players */ +const soloMatchPool = []; + +class Room { + constructor(id, host) { + this.id = id; + this.host = host; // WebSocket of player 1 + this.guest = null; // WebSocket of player 2 + this.state = 'waiting'; // waiting | playing | finished + this.createdAt = Date.now(); + this.mapId = Math.floor(Math.random() * 3) + 1; + } + + isFull() { + return this.host && this.guest; + } + + getOpponent(ws) { + if (ws === this.host) return this.guest; + if (ws === this.guest) return this.host; + return null; + } + + getPlayerSlot(ws) { + if (ws === this.host) return 1; + if (ws === this.guest) return 2; + return 0; + } + + removePlayer(ws) { + if (ws === this.host) this.host = null; + if (ws === this.guest) this.guest = null; + } + + isEmpty() { + return !this.host && !this.guest; + } +} + +class PlayerInfo { + constructor(ws, playerId) { + this.ws = ws; + this.playerId = playerId; + this.roomId = null; + this.teamId = null; + this.isAlive = true; + this.lastPing = Date.now(); + } +} + +// ============================================================ +// TeamRoom - 3v3 Team Room Management +// ============================================================ + +class TeamRoom { + /** + * @param {string} id - Unique team room id + * @param {WebSocket} leaderWs - WebSocket of the team leader + * @param {string} leaderId - Player id of the leader + * @param {string} [battleMode='3v3'] - Battle mode ('1v1' or '3v3') + */ + constructor(id, leaderWs, leaderId, battleMode = '3v3') { + this.id = id; + this.state = 'forming'; // forming | matching | playing | finished + this.createdAt = Date.now(); + this.mapId = Math.floor(Math.random() * 3) + 1; + this.battleMode = battleMode; + + const config = BATTLE_CONFIG[battleMode] || BATTLE_CONFIG['3v3']; + this.teamSize = config.teamSize; + this.fillWithBotsEnabled = config.fillWithBots; + + // Team A members: { ws, playerId, ready, isBot, disconnectedAt } + this.teamA = [{ ws: leaderWs, playerId: leaderId, ready: true, isBot: false, disconnectedAt: null }]; + // Team B members + this.teamB = []; + this.leaderId = leaderId; + + // Matching state + this.matchStartTime = null; + this.matchTimer = null; + + // Game state + this.teamABaseHp = config.baseHp; + this.teamBBaseHp = config.baseHp; + this.gameStartTime = null; + } + + /** Get all members of team A */ + getTeamAMembers() { + return this.teamA; + } + + /** Get all members of team B */ + getTeamBMembers() { + return this.teamB; + } + + /** Get all human (non-bot) members across both teams */ + getAllHumanMembers() { + return [...this.teamA, ...this.teamB].filter(m => !m.isBot); + } + + /** Get all members across both teams */ + getAllMembers() { + return [...this.teamA, ...this.teamB]; + } + + /** Find which team a player belongs to */ + getPlayerTeam(playerId) { + if (this.teamA.find(m => m.playerId === playerId)) return 'A'; + if (this.teamB.find(m => m.playerId === playerId)) return 'B'; + return null; + } + + /** Find a member by playerId */ + getMember(playerId) { + return this.getAllMembers().find(m => m.playerId === playerId) || null; + } + + /** Find a member by WebSocket */ + getMemberByWs(ws) { + return this.getAllMembers().find(m => m.ws === ws) || null; + } + + /** Check if team A is full */ + isTeamAFull() { + return this.teamA.length >= this.teamSize; + } + + /** Check if both teams are full */ + isFull() { + return this.teamA.length >= this.teamSize && this.teamB.length >= this.teamSize; + } + + /** Check if all team A members are ready */ + isTeamAReady() { + return this.teamA.length > 0 && this.teamA.every(m => m.ready || m.isBot); + } + + /** Add a player to team A */ + addToTeamA(ws, playerId) { + if (this.isTeamAFull()) return false; + this.teamA.push({ ws, playerId, ready: false, isBot: false, disconnectedAt: null }); + return true; + } + + /** Add a player to team B */ + addToTeamB(ws, playerId) { + if (this.teamB.length >= this.teamSize) return false; + this.teamB.push({ ws, playerId, ready: false, isBot: false, disconnectedAt: null }); + return true; + } + + /** Remove a player from the team room */ + removePlayer(playerId) { + this.teamA = this.teamA.filter(m => m.playerId !== playerId); + this.teamB = this.teamB.filter(m => m.playerId !== playerId); + } + + /** Fill remaining slots with AI bots */ + fillWithBots() { + let botCounter = 0; + while (this.teamA.length < this.teamSize) { + botCounter++; + this.teamA.push({ + ws: null, + playerId: `bot_a_${botCounter}_${this.id}`, + ready: true, + isBot: true, + disconnectedAt: null, + }); + } + while (this.teamB.length < this.teamSize) { + botCounter++; + this.teamB.push({ + ws: null, + playerId: `bot_b_${botCounter}_${this.id}`, + ready: true, + isBot: true, + disconnectedAt: null, + }); + } + } + + /** Broadcast a message to all human members in the room */ + broadcast(type, data, excludeWs = null) { + for (const member of this.getAllHumanMembers()) { + if (member.ws && member.ws !== excludeWs && member.ws.readyState === 1) { + sendMessage(member.ws, type, data); + } + } + } + + /** + * Reset the room for a rematch: clear game state, keep players. + * Returns the room to 'forming' state so a new game can start. + */ + resetForRematch() { + this.state = 'forming'; + this.mapId = Math.floor(Math.random() * 3) + 1; + this.matchStartTime = null; + if (this.matchTimer) { + clearTimeout(this.matchTimer); + this.matchTimer = null; + } + + const config = BATTLE_CONFIG[this.battleMode] || BATTLE_CONFIG['3v3']; + this.teamABaseHp = config.baseHp; + this.teamBBaseHp = config.baseHp; + this.gameStartTime = null; + + // Reset rematch tracking + this._rematchPlayers = new Set(); + this._rematchTimer = null; + + // Reset ready state for all human members + for (const member of this.getAllHumanMembers()) { + member.disconnectedAt = null; + member.isBot = false; + } + } + + /** Broadcast to all members of a specific team */ + broadcastToTeam(team, type, data, excludeWs = null) { + const members = team === 'A' ? this.teamA : this.teamB; + for (const member of members) { + if (member.ws && !member.isBot && member.ws !== excludeWs && member.ws.readyState === 1) { + sendMessage(member.ws, type, data); + } + } + } + + /** Get serializable team state for broadcasting */ + getTeamState() { + return { + teamId: this.id, + state: this.state, + leaderId: this.leaderId, + battleMode: this.battleMode, + teamSize: this.teamSize, + teamA: this.teamA.map(m => ({ + playerId: m.playerId, + ready: m.ready, + isBot: m.isBot, + isLeader: m.playerId === this.leaderId, + connected: m.isBot || (m.ws && m.ws.readyState === 1), + })), + teamB: this.teamB.map(m => ({ + playerId: m.playerId, + ready: m.ready, + isBot: m.isBot, + connected: m.isBot || (m.ws && m.ws.readyState === 1), + })), + teamABaseHp: this.teamABaseHp, + teamBBaseHp: this.teamBBaseHp, + }; + } +} + +// ============================================================ +// Utility Functions +// ============================================================ + +/** + * Generate a random room code (4-6 digit number). + * @returns {string} + */ +function generateRoomCode() { + let code; + do { + code = String(Math.floor(1000 + Math.random() * 9000)); // 4-digit code + } while (rooms.has(code)); + return code; +} + +/** + * Send a JSON message to a WebSocket client. + * @param {WebSocket} ws + * @param {string} type + * @param {object} data + */ +function sendMessage(ws, type, data = {}) { + if (!ws || ws.readyState !== 1) return; // 1 = OPEN + try { + ws.send(JSON.stringify({ type, data, timestamp: Date.now() })); + } catch (e) { + console.error('[Server] Send error:', e.message); + } +} + +/** + * Relay a message from one player to their opponent. + * @param {WebSocket} senderWs + * @param {string} type + * @param {object} data + */ +function relayToOpponent(senderWs, type, data) { + const playerInfo = players.get(senderWs); + if (!playerInfo || !playerInfo.roomId) return; + + const room = rooms.get(playerInfo.roomId); + if (!room) return; + + const opponent = room.getOpponent(senderWs); + if (opponent) { + sendMessage(opponent, type, data); + } +} + +/** + * Generate a unique team room id. + * @returns {string} + */ +function generateTeamId() { + let id; + do { + id = 'T' + String(Math.floor(10000 + Math.random() * 90000)); + } while (teamRooms.has(id)); + return id; +} + +/** + * Relay a message from one player to all other players in the same team room. + * @param {WebSocket} senderWs + * @param {string} type + * @param {object} data + */ +function relayToTeamRoom(senderWs, type, data) { + const playerInfo = players.get(senderWs); + if (!playerInfo || !playerInfo.teamId) return; + + const teamRoom = teamRooms.get(playerInfo.teamId); + if (!teamRoom) return; + + teamRoom.broadcast(type, data, senderWs); +} + +// ============================================================ +// Message Handlers +// ============================================================ + +function handleCreateRoom(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo) return; + + // Leave current room/team if any + if (playerInfo.roomId) { + leaveRoom(ws); + } + if (playerInfo.teamId) { + handleLeaveTeam(ws, {}); + } + + const roomCode = generateRoomCode(); + + // Create a TeamRoom in 1v1 mode instead of a legacy Room + const teamRoom = new TeamRoom(roomCode, ws, playerInfo.playerId, '1v1'); + teamRooms.set(roomCode, teamRoom); + playerInfo.teamId = roomCode; + + console.log(`[Server] 1v1 Room ${roomCode} created by ${playerInfo.playerId} (using TeamRoom)`); + + sendMessage(ws, NET_MSG.ROOM_CREATED, { + roomId: roomCode, + roomCode: roomCode, + playerSlot: 1, + }); +} + +function handleJoinRoom(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo) return; + + const roomId = data.roomId; + if (!roomId) { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Room code is required' }); + return; + } + + // Look up in teamRooms (1v1 rooms are now TeamRooms) + const teamRoom = teamRooms.get(roomId); + if (!teamRoom) { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Room not found' }); + return; + } + + if (teamRoom.battleMode !== '1v1') { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Room not found' }); + return; + } + + if (teamRoom.teamB.length >= teamRoom.teamSize) { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Room is full' }); + return; + } + + // Leave current room/team if any + if (playerInfo.roomId) { + leaveRoom(ws); + } + if (playerInfo.teamId) { + handleLeaveTeam(ws, {}); + } + + // Join as team B + teamRoom.addToTeamB(ws, playerInfo.playerId); + playerInfo.teamId = roomId; + + console.log(`[Server] Player ${playerInfo.playerId} joined 1v1 room ${roomId} (TeamRoom)`); + + // Notify the joiner + sendMessage(ws, NET_MSG.ROOM_JOINED, { + roomId: roomId, + roomCode: roomId, + playerSlot: 2, + }); + + // Notify the host + const host = teamRoom.teamA[0]; + if (host && host.ws) { + sendMessage(host.ws, NET_MSG.OPPONENT_JOINED, { + playerId: playerInfo.playerId, + }); + } + + // Start game after countdown + teamRoom.state = 'playing'; + + setTimeout(() => { + if (teamRoom.state === 'playing' && teamRoom.teamA.length > 0 && teamRoom.teamB.length > 0) { + startTeamGame(teamRoom); + } + }, 3500); // 3.5s countdown +} + +function leaveRoom(ws) { + const playerInfo = players.get(ws); + if (!playerInfo || !playerInfo.roomId) return; + + const room = rooms.get(playerInfo.roomId); + if (!room) { + playerInfo.roomId = null; + return; + } + + const opponent = room.getOpponent(ws); + room.removePlayer(ws); + playerInfo.roomId = null; + + // Notify opponent + if (opponent) { + sendMessage(opponent, NET_MSG.OPPONENT_LEFT, {}); + } + + // Clean up empty room + if (room.isEmpty()) { + rooms.delete(room.id); + console.log(`[Server] Room ${room.id} deleted (empty)`); + } +} + +// ============================================================ +// 3v3 Team Message Handlers +// ============================================================ + +function handleCreateTeam(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo) return; + + // Leave current team if any + if (playerInfo.teamId) { + handleLeaveTeam(ws, {}); + } + + const teamId = generateTeamId(); + const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId); + teamRooms.set(teamId, teamRoom); + playerInfo.teamId = teamId; + + console.log(`[Server] Team ${teamId} created by ${playerInfo.playerId}`); + + sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); +} + +function handleJoinTeam(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo) return; + + const teamId = data.teamId; + if (!teamId) { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Team ID is required' }); + return; + } + + let teamRoom = teamRooms.get(teamId); + if (!teamRoom) { + // Team was cleaned up (e.g. leader disconnected during dev-tool reload). + // Auto-create a new room with the same ID so the invite link still works. + console.log(`[Server] Team ${teamId} not found, auto-creating for ${playerInfo.playerId}`); + teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId); + teamRooms.set(teamId, teamRoom); + playerInfo.teamId = teamId; + sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); + return; + } + + if (teamRoom.state !== 'forming') { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Team is not accepting members' }); + return; + } + + if (teamRoom.isTeamAFull()) { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Team is full' }); + return; + } + + // Leave current team if any + if (playerInfo.teamId) { + handleLeaveTeam(ws, {}); + } + + teamRoom.addToTeamA(ws, playerInfo.playerId); + playerInfo.teamId = teamId; + + console.log(`[Server] Player ${playerInfo.playerId} joined team ${teamId}`); + + // Broadcast updated team state to all members + teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); +} + +function handleLeaveTeam(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo || !playerInfo.teamId) return; + + const teamRoom = teamRooms.get(playerInfo.teamId); + if (!teamRoom) { + playerInfo.teamId = null; + return; + } + + // If team is matching and leader leaves, cancel match first + const wasLeader = teamRoom.leaderId === playerInfo.playerId; + if (teamRoom.state === 'matching' && wasLeader) { + cancelMatch(teamRoom); + } + + teamRoom.removePlayer(playerInfo.playerId); + playerInfo.teamId = null; + + // Remove from solo match pool if present + const soloIdx = soloMatchPool.indexOf(ws); + if (soloIdx !== -1) soloMatchPool.splice(soloIdx, 1); + + console.log(`[Server] Player ${playerInfo.playerId} left team ${teamRoom.id}`); + + // If leader left, assign new leader or disband + if (wasLeader) { + const remainingHumans = teamRoom.teamA.filter(m => !m.isBot); + if (remainingHumans.length > 0) { + teamRoom.leaderId = remainingHumans[0].playerId; + console.log(`[Server] New leader for team ${teamRoom.id}: ${teamRoom.leaderId}`); + teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); + } else { + // No humans left, disband + cleanupTeamRoom(teamRoom.id); + } + } else { + teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); + } +} + +function handleTeamReady(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo || !playerInfo.teamId) return; + + const teamRoom = teamRooms.get(playerInfo.teamId); + if (!teamRoom || teamRoom.state !== 'forming') return; + + const member = teamRoom.getMember(playerInfo.playerId); + if (!member) return; + + // Leader is always ready; toggle ready for non-leaders + if (playerInfo.playerId !== teamRoom.leaderId) { + member.ready = data.ready !== undefined ? !!data.ready : !member.ready; + } + + teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); +} + +function handleTeamKick(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo || !playerInfo.teamId) return; + + const teamRoom = teamRooms.get(playerInfo.teamId); + if (!teamRoom) return; + + // Only leader can kick, and only during forming state + if (teamRoom.leaderId !== playerInfo.playerId) { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Only the leader can kick players' }); + return; + } + + if (teamRoom.state !== 'forming') { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Cannot kick during matching or game' }); + return; + } + + const targetId = data.playerId; + if (!targetId || targetId === playerInfo.playerId) return; + + const targetMember = teamRoom.getMember(targetId); + if (!targetMember) return; + + // Notify the kicked player + if (targetMember.ws && targetMember.ws.readyState === 1) { + sendMessage(targetMember.ws, NET_MSG.TEAM_DISBAND, { reason: 'kicked' }); + const targetInfo = players.get(targetMember.ws); + if (targetInfo) targetInfo.teamId = null; + } + + teamRoom.removePlayer(targetId); + console.log(`[Server] Player ${targetId} kicked from team ${teamRoom.id}`); + + teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); +} + +function handleTeamDisband(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo || !playerInfo.teamId) return; + + const teamRoom = teamRooms.get(playerInfo.teamId); + if (!teamRoom) return; + + // Only leader can disband + if (teamRoom.leaderId !== playerInfo.playerId) { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Only the leader can disband' }); + return; + } + + console.log(`[Server] Team ${teamRoom.id} disbanded by leader`); + + // Notify all members + teamRoom.broadcast(NET_MSG.TEAM_DISBAND, { reason: 'disbanded' }); + + // Clear teamId for all human members + for (const member of teamRoom.getAllHumanMembers()) { + if (member.ws) { + const info = players.get(member.ws); + if (info) info.teamId = null; + } + } + + cleanupTeamRoom(teamRoom.id); +} + +function handleMatchStart(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo || !playerInfo.teamId) return; + + const teamRoom = teamRooms.get(playerInfo.teamId); + if (!teamRoom) return; + + // Only leader can start matching + if (teamRoom.leaderId !== playerInfo.playerId) { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Only the leader can start matching' }); + return; + } + + if (teamRoom.state !== 'forming') return; + + // Check all team A members are ready + if (!teamRoom.isTeamAReady()) { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Not all team members are ready' }); + return; + } + + teamRoom.state = 'matching'; + teamRoom.matchStartTime = Date.now(); + + // Add to match pool + teamMatchPool.push(teamRoom); + + console.log(`[Server] Team ${teamRoom.id} entered matching pool`); + + // Broadcast matching state + teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); + + // Set match timeout + teamRoom.matchTimer = setTimeout(() => { + if (teamRoom.state === 'matching') { + handleMatchTimeout(teamRoom); + } + }, TEAM_MATCH_TIMEOUT); + + // Try to match immediately + tryMatchTeams(); +} + +function handleMatchCancel(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo || !playerInfo.teamId) return; + + const teamRoom = teamRooms.get(playerInfo.teamId); + if (!teamRoom || teamRoom.state !== 'matching') return; + + // Only leader can cancel + if (teamRoom.leaderId !== playerInfo.playerId) return; + + cancelMatch(teamRoom); +} + +function handleSoloMatch(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo) return; + + // Leave current team if any + if (playerInfo.teamId) { + handleLeaveTeam(ws, {}); + } + + // Create a solo team room for this player + const teamId = generateTeamId(); + const teamRoom = new TeamRoom(teamId, ws, playerInfo.playerId); + teamRoom.state = 'matching'; + teamRoom.matchStartTime = Date.now(); + teamRooms.set(teamId, teamRoom); + playerInfo.teamId = teamId; + + // Add to solo match pool + soloMatchPool.push(ws); + + console.log(`[Server] Player ${playerInfo.playerId} entered solo match pool (team ${teamId})`); + + sendMessage(ws, NET_MSG.TEAM_STATE, teamRoom.getTeamState()); + + // Set match timeout + teamRoom.matchTimer = setTimeout(() => { + if (teamRoom.state === 'matching') { + handleMatchTimeout(teamRoom); + } + }, TEAM_MATCH_TIMEOUT); + + // Try to match + tryMatchTeams(); +} + +function cancelMatch(teamRoom) { + if (teamRoom.matchTimer) { + clearTimeout(teamRoom.matchTimer); + teamRoom.matchTimer = null; + } + + teamRoom.state = 'forming'; + teamRoom.matchStartTime = null; + + // Remove from match pool + const idx = teamMatchPool.indexOf(teamRoom); + if (idx !== -1) teamMatchPool.splice(idx, 1); + + // Remove solo players from solo pool + for (const member of teamRoom.teamA) { + const soloIdx = soloMatchPool.indexOf(member.ws); + if (soloIdx !== -1) soloMatchPool.splice(soloIdx, 1); + } + + console.log(`[Server] Match cancelled for team ${teamRoom.id}`); + teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); +} + +function handleMatchTimeout(teamRoom) { + console.log(`[Server] Match timeout for team ${teamRoom.id}, filling with bots`); + + // Remove from match pool + const idx = teamMatchPool.indexOf(teamRoom); + if (idx !== -1) teamMatchPool.splice(idx, 1); + + // Fill remaining slots with bots + teamRoom.fillWithBots(); + + // Start the game + startTeamGame(teamRoom); +} + +/** + * Try to match teams from the pool. + * Simple matching: pair two teams or combine solo players. + */ +function tryMatchTeams() { + // Try to pair two team queues + if (teamMatchPool.length >= 2) { + const teamA_room = teamMatchPool.shift(); + const teamB_room = teamMatchPool.shift(); + + // Merge team B members into team A room as opponents + for (const member of teamB_room.teamA) { + teamA_room.addToTeamB(member.ws, member.playerId); + if (member.ws) { + const info = players.get(member.ws); + if (info) info.teamId = teamA_room.id; + } + } + + // Clean up team B room + if (teamB_room.matchTimer) clearTimeout(teamB_room.matchTimer); + teamRooms.delete(teamB_room.id); + + // Fill remaining with bots if needed + teamA_room.fillWithBots(); + + console.log(`[Server] Teams matched: ${teamA_room.id}`); + startTeamGame(teamA_room); + return; + } + + // Try to combine solo players into teams + // Collect all solo players that are in matching state + const availableSolos = soloMatchPool.filter(ws => { + const info = players.get(ws); + if (!info || !info.teamId) return false; + const room = teamRooms.get(info.teamId); + return room && room.state === 'matching'; + }); + + if (availableSolos.length >= 2) { + // Take up to 10 solo players and form a game + const gamePlayers = availableSolos.splice(0, Math.min(10, availableSolos.length)); + + // Remove from solo pool + for (const ws of gamePlayers) { + const idx = soloMatchPool.indexOf(ws); + if (idx !== -1) soloMatchPool.splice(idx, 1); + } + + // Use the first player's team room as the game room + const firstInfo = players.get(gamePlayers[0]); + const gameRoom = teamRooms.get(firstInfo.teamId); + + // Clear match timer + if (gameRoom.matchTimer) { + clearTimeout(gameRoom.matchTimer); + gameRoom.matchTimer = null; + } + + // Add remaining players, alternating teams + for (let i = 1; i < gamePlayers.length; i++) { + const ws = gamePlayers[i]; + const info = players.get(ws); + if (!info) continue; + + // Clean up their old solo team room + const oldRoom = teamRooms.get(info.teamId); + if (oldRoom && oldRoom.id !== gameRoom.id) { + if (oldRoom.matchTimer) clearTimeout(oldRoom.matchTimer); + teamRooms.delete(oldRoom.id); + } + + info.teamId = gameRoom.id; + + // Alternate: odd index -> team A, even index -> team B + if (i % 2 === 1 && !gameRoom.isTeamAFull()) { + gameRoom.addToTeamA(ws, info.playerId); + } else { + gameRoom.addToTeamB(ws, info.playerId); + } + } + + // Fill with bots + gameRoom.fillWithBots(); + + console.log(`[Server] Solo players matched into team ${gameRoom.id}`); + startTeamGame(gameRoom); + } +} + +/** + * Start a team game (supports both 1v1 and 3v3). + * @param {TeamRoom} teamRoom + */ +function startTeamGame(teamRoom) { + teamRoom.state = 'playing'; + teamRoom.gameStartTime = Date.now(); + + const config = BATTLE_CONFIG[teamRoom.battleMode] || BATTLE_CONFIG['3v3']; + teamRoom.teamABaseHp = config.baseHp; + teamRoom.teamBBaseHp = config.baseHp; + + if (teamRoom.matchTimer) { + clearTimeout(teamRoom.matchTimer); + teamRoom.matchTimer = null; + } + + const gameData = { + mapId: teamRoom.mapId, + teamA: teamRoom.teamA.map(m => ({ playerId: m.playerId, isBot: m.isBot })), + teamB: teamRoom.teamB.map(m => ({ playerId: m.playerId, isBot: m.isBot })), + teamABaseHp: teamRoom.teamABaseHp, + teamBBaseHp: teamRoom.teamBBaseHp, + battleMode: teamRoom.battleMode, + roomId: teamRoom.id, + }; + + console.log(`[Server] ${teamRoom.battleMode} game starting in room ${teamRoom.id}`); + + // For 1v1, use GAME_START message (client RoomScene listens for it) + // For 3v3, use TEAM_GAME_START message (client TeamRoomScene listens for it) + if (teamRoom.battleMode === '1v1') { + // Send immediately (countdown already happened in handleJoinRoom) + teamRoom.broadcast(NET_MSG.GAME_START, gameData); + } else { + // Notify all players with a short delay for loading + setTimeout(() => { + teamRoom.broadcast(NET_MSG.TEAM_GAME_START, gameData); + }, 3000); + } +} + +/** + * End a team game (supports both 1v1 and 3v3). + * @param {TeamRoom} teamRoom + * @param {string} reason - 'base_destroyed' (only valid reason) + */ +function endTeamGame(teamRoom, reason) { + if (teamRoom.state !== 'playing') return; + teamRoom.state = 'finished'; + + // Determine winner: the team whose base is destroyed loses + let winner = ''; + if (teamRoom.teamABaseHp <= 0) winner = 'B'; + else if (teamRoom.teamBBaseHp <= 0) winner = 'A'; + + const resultData = { + winner, + reason: 'base_destroyed', + teamABaseHp: teamRoom.teamABaseHp, + teamBBaseHp: teamRoom.teamBBaseHp, + battleMode: teamRoom.battleMode, + }; + + console.log(`[Server] ${teamRoom.battleMode} game ended in room ${teamRoom.id}, winner: ${winner}, reason: base_destroyed`); + + // Use appropriate message type + if (teamRoom.battleMode === '1v1') { + teamRoom.broadcast(NET_MSG.GAME_OVER, resultData); + } else { + teamRoom.broadcast(NET_MSG.TEAM_GAME_OVER, resultData); + } + + // Initialize rematch tracking + teamRoom._rematchPlayers = new Set(); + + // Set a cleanup timer: if no rematch within 60s, clean up + teamRoom._rematchTimer = setTimeout(() => { + if (teamRoom.state === 'finished') { + cleanupTeamRoom(teamRoom.id); + } + }, 60000); // 60s for result screen + rematch window +} + +function handleBaseHit(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo || !playerInfo.teamId) return; + + const teamRoom = teamRooms.get(playerInfo.teamId); + if (!teamRoom || teamRoom.state !== 'playing') return; + + const { targetTeam, damage } = data; + const dmg = Math.min(damage || 1, 3); // Cap damage to prevent abuse + + // Validate targetTeam value + if (targetTeam !== 'A' && targetTeam !== 'B') return; + + if (targetTeam === 'A') { + teamRoom.teamABaseHp = Math.max(0, teamRoom.teamABaseHp - dmg); + } else if (targetTeam === 'B') { + teamRoom.teamBBaseHp = Math.max(0, teamRoom.teamBBaseHp - dmg); + } + + // Broadcast base hit to all players + teamRoom.broadcast(NET_MSG.BASE_HIT, { + targetTeam, + damage: dmg, + teamABaseHp: teamRoom.teamABaseHp, + teamBBaseHp: teamRoom.teamBBaseHp, + }); + + // Check if base destroyed + if (teamRoom.teamABaseHp <= 0 || teamRoom.teamBBaseHp <= 0) { + endTeamGame(teamRoom, 'base_destroyed'); + } +} + +function handleTeamPlayerDisconnect(ws) { + const playerInfo = players.get(ws); + if (!playerInfo || !playerInfo.teamId) return; + + const teamRoom = teamRooms.get(playerInfo.teamId); + if (!teamRoom) return; + + const member = teamRoom.getMemberByWs(ws); + if (!member) return; + + if (teamRoom.state === 'playing') { + if (teamRoom.battleMode === '1v1') { + // 1v1 mode: opponent left = immediate win for the other player + member.disconnectedAt = Date.now(); + member.ws = null; + + console.log(`[Server] Player ${member.playerId} disconnected from 1v1 game ${teamRoom.id}`); + + teamRoom.broadcast(NET_MSG.OPPONENT_LEFT, { playerId: member.playerId }); + } else { + // 3v3 mode: mark as disconnected, allow reconnect with bot takeover + member.disconnectedAt = Date.now(); + member.ws = null; + + console.log(`[Server] Player ${member.playerId} disconnected from team game ${teamRoom.id}`); + + teamRoom.broadcast(NET_MSG.PLAYER_DISCONNECT, { playerId: member.playerId }); + + // Set bot takeover timer + setTimeout(() => { + // Only take over if still disconnected and game is still playing + if (member.disconnectedAt && !member.ws && teamRoom.state === 'playing') { + member.isBot = true; + console.log(`[Server] Bot takeover for ${member.playerId} in team ${teamRoom.id}`); + teamRoom.broadcast(NET_MSG.BOT_TAKEOVER, { playerId: member.playerId }); + } + }, TEAM_RECONNECT_TIMEOUT); + } + } else if (teamRoom.state === 'finished') { + // Game finished, keep player in room for potential rematch + // Just mark as disconnected but don't remove from room + member.disconnectedAt = Date.now(); + member.ws = null; + console.log(`[Server] Player ${member.playerId} disconnected from finished game ${teamRoom.id} (kept for rematch)`); + } else if (teamRoom.state === 'matching') { + // During matching, cancel match and remove player + if (teamRoom.leaderId === playerInfo.playerId) { + cancelMatch(teamRoom); + } + handleLeaveTeam(ws, {}); + } else { + // Not in game, just remove + // For 1v1 waiting rooms, notify the other player + if (teamRoom.battleMode === '1v1') { + teamRoom.broadcast(NET_MSG.OPPONENT_LEFT, { playerId: member.playerId }, ws); + } + handleLeaveTeam(ws, {}); + } +} + +function handleReconnect(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo) return; + + const { teamId, playerId } = data; + if (!teamId || !playerId) return; + + const teamRoom = teamRooms.get(teamId); + if (!teamRoom || teamRoom.state !== 'playing') { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Game not found or already ended' }); + return; + } + + const member = teamRoom.getMember(playerId); + if (!member || !member.disconnectedAt) { + sendMessage(ws, NET_MSG.ROOM_ERROR, { message: 'Cannot reconnect' }); + return; + } + + // Reconnect successful + member.ws = ws; + member.disconnectedAt = null; + member.isBot = false; + playerInfo.teamId = teamId; + playerInfo.playerId = playerId; + + console.log(`[Server] Player ${playerId} reconnected to team ${teamId}`); + + // Send current game state + sendMessage(ws, NET_MSG.RECONNECT_OK, { + teamState: teamRoom.getTeamState(), + mapId: teamRoom.mapId, + teamABaseHp: teamRoom.teamABaseHp, + teamBBaseHp: teamRoom.teamBBaseHp, + elapsed: Math.floor((Date.now() - teamRoom.gameStartTime) / 1000), + }); + + teamRoom.broadcast(NET_MSG.TEAM_STATE, teamRoom.getTeamState()); +} + +/** + * Handle a rematch request from a player. + * When all human players in the room request rematch, reset and restart. + */ +function handleRematch(ws, data) { + const playerInfo = players.get(ws); + if (!playerInfo || !playerInfo.teamId) { + console.log(`[Server] Rematch rejected: no playerInfo or teamId`, playerInfo ? playerInfo.playerId : 'unknown'); + return; + } + + const teamRoom = teamRooms.get(playerInfo.teamId); + if (!teamRoom || teamRoom.state !== 'finished') { + console.log(`[Server] Rematch rejected: room not found or not finished`, playerInfo.teamId, teamRoom ? teamRoom.state : 'no room'); + return; + } + + // Initialize rematch set if needed + if (!teamRoom._rematchPlayers) { + teamRoom._rematchPlayers = new Set(); + } + + // Update the member's ws reference (in case of reconnect) + const member = teamRoom.getMember(playerInfo.playerId); + if (member) { + member.ws = ws; + member.disconnectedAt = null; + } + + teamRoom._rematchPlayers.add(playerInfo.playerId); + + // Count all human members (connected or not) + const allHumans = teamRoom.getAllHumanMembers(); + console.log(`[Server] Rematch request from ${playerInfo.playerId} in room ${teamRoom.id} (${teamRoom._rematchPlayers.size}/${allHumans.length})`); + + // Notify all players about who wants rematch + teamRoom.broadcast(NET_MSG.REMATCH_READY, { + playerId: playerInfo.playerId, + readyCount: teamRoom._rematchPlayers.size, + totalCount: allHumans.length, + }); + + // Check if all human players who are still connected want rematch + const connectedHumans = allHumans.filter( + m => m.ws && m.ws.readyState === 1 + ); + const allReady = connectedHumans.length > 0 && connectedHumans.every( + m => teamRoom._rematchPlayers.has(m.playerId) + ); + + console.log(`[Server] Rematch check: connectedHumans=${connectedHumans.length}, allReady=${allReady}, rematchPlayers=[${[...teamRoom._rematchPlayers].join(',')}]`); + + if (allReady) { + // Clear the cleanup timer + if (teamRoom._rematchTimer) { + clearTimeout(teamRoom._rematchTimer); + teamRoom._rematchTimer = null; + } + + console.log(`[Server] All players ready for rematch in room ${teamRoom.id}`); + + // Reset room for rematch + teamRoom.resetForRematch(); + + // Remove any bot members from previous game (they'll be re-filled) + teamRoom.teamA = teamRoom.teamA.filter(m => !m.isBot); + teamRoom.teamB = teamRoom.teamB.filter(m => !m.isBot); + + // Fill with bots if needed + if (teamRoom.fillWithBotsEnabled) { + teamRoom.fillWithBots(); + } + + // Start the game again after a short delay + if (teamRoom.battleMode === '1v1') { + teamRoom.state = 'playing'; + setTimeout(() => { + startTeamGame(teamRoom); + }, 3500); // 3.5s countdown + } else { + teamRoom.state = 'playing'; + startTeamGame(teamRoom); + } + } +} + +function cleanupTeamRoom(teamId) { + const teamRoom = teamRooms.get(teamId); + if (!teamRoom) return; + + if (teamRoom.matchTimer) clearTimeout(teamRoom.matchTimer); + if (teamRoom._rematchTimer) clearTimeout(teamRoom._rematchTimer); + + // Clear teamId for all human members + for (const member of teamRoom.getAllHumanMembers()) { + if (member.ws) { + const info = players.get(member.ws); + if (info) info.teamId = null; + } + } + + // Remove from match pool + const idx = teamMatchPool.indexOf(teamRoom); + if (idx !== -1) teamMatchPool.splice(idx, 1); + + teamRooms.delete(teamId); + console.log(`[Server] Team room ${teamId} cleaned up`); +} + +// ============================================================ +// Message Handlers +// ============================================================ + +function handleMessage(ws, rawData) { + let msg; + try { + msg = JSON.parse(rawData); + } catch (e) { + console.error('[Server] Invalid JSON:', rawData); + return; + } + + const { type, data, playerId } = msg; + + // Update player info + const playerInfo = players.get(ws); + if (playerInfo) { + playerInfo.lastPing = Date.now(); + if (playerId && !playerInfo.playerId) { + playerInfo.playerId = playerId; + } + } + + switch (type) { + case NET_MSG.PING: + sendMessage(ws, NET_MSG.PONG, {}); + break; + + // 1v1 PVP + case NET_MSG.CREATE_ROOM: + handleCreateRoom(ws, data || {}); + break; + + case NET_MSG.JOIN_ROOM: + handleJoinRoom(ws, data || {}); + break; + + // Relay gameplay messages + case NET_MSG.PLAYER_INPUT: + case NET_MSG.PLAYER_STATE: + case NET_MSG.BULLET_FIRE: + case NET_MSG.BULLET_HIT: + case NET_MSG.PLAYER_HIT: + case NET_MSG.PLAYER_KILLED: + case NET_MSG.GAME_OVER: + // All modes now use teamRoom relay + if (playerInfo && playerInfo.teamId) { + relayToTeamRoom(ws, type, { ...data, playerId: playerInfo.playerId }); + } else if (playerInfo && playerInfo.roomId) { + relayToOpponent(ws, type, data || {}); + } + break; + + // 3v3 Team messages + case NET_MSG.CREATE_TEAM: + handleCreateTeam(ws, data || {}); + break; + + case NET_MSG.JOIN_TEAM: + handleJoinTeam(ws, data || {}); + break; + + case NET_MSG.LEAVE_TEAM: + handleLeaveTeam(ws, data || {}); + break; + + case NET_MSG.TEAM_READY: + handleTeamReady(ws, data || {}); + break; + + case NET_MSG.TEAM_KICK: + handleTeamKick(ws, data || {}); + break; + + case NET_MSG.TEAM_DISBAND: + handleTeamDisband(ws, data || {}); + break; + + case NET_MSG.MATCH_START: + handleMatchStart(ws, data || {}); + break; + + case NET_MSG.MATCH_CANCEL: + handleMatchCancel(ws, data || {}); + break; + + case NET_MSG.SOLO_MATCH: + handleSoloMatch(ws, data || {}); + break; + + case NET_MSG.BASE_HIT: + // All modes now use server-authoritative base HP tracking via TeamRoom + if (playerInfo && playerInfo.teamId) { + handleBaseHit(ws, data || {}); + } else if (playerInfo && playerInfo.roomId) { + // Legacy fallback for old 1v1 rooms + relayToOpponent(ws, type, data || {}); + } + break; + + case NET_MSG.PLAYER_RESPAWN: + if (playerInfo && playerInfo.teamId) { + relayToTeamRoom(ws, type, { ...data, playerId: playerInfo.playerId }); + } + break; + + case NET_MSG.RECONNECT: + handleReconnect(ws, data || {}); + break; + + case NET_MSG.REMATCH: + handleRematch(ws, data || {}); + break; + + default: + console.warn(`[Server] Unknown message type: ${type}`); + } +} + +// ============================================================ +// Anti-Cheat & Rate Limiting +// ============================================================ + +/** @type {Map} IP-based ad request tracking */ +const adRequestTracker = new Map(); + +/** @type {Map} IP-based purchase tracking for anomaly detection */ +const purchaseTracker = new Map(); + +/** + * Check if an IP has exceeded the ad request rate limit. + * @param {string} ip + * @returns {boolean} true if rate limited + */ +function isAdRateLimited(ip) { + const now = Date.now(); + const tracker = adRequestTracker.get(ip); + + if (!tracker || now > tracker.resetTime) { + adRequestTracker.set(ip, { count: 1, resetTime: now + 60000 }); // 1 minute window + return false; + } + + tracker.count++; + // More than 20 ad requests per minute is suspicious + if (tracker.count > 20) { + console.warn(`[AntiCheat] Ad rate limit triggered for IP: ${ip} (${tracker.count} requests/min)`); + return true; + } + return false; +} + +/** + * Check for suspicious purchase patterns. + * @param {string} ip + * @param {number} amount - Purchase amount in fen + * @returns {{ suspicious: boolean, reason?: string }} + */ +function checkPurchaseAnomaly(ip, amount) { + const now = Date.now(); + if (!purchaseTracker.has(ip)) { + purchaseTracker.set(ip, []); + } + + const history = purchaseTracker.get(ip); + // Clean old entries (keep last 24 hours) + const cutoff = now - 24 * 60 * 60 * 1000; + while (history.length > 0 && history[0] < cutoff) { + history.shift(); + } + + history.push(now); + + // Flag: more than 10 purchases in 24 hours + if (history.length > 10) { + console.warn(`[AntiCheat] Suspicious purchase frequency from IP: ${ip} (${history.length} in 24h)`); + return { suspicious: true, reason: 'high_frequency' }; + } + + // Flag: single purchase > ¥500 (50000 fen) + if (amount > 50000) { + console.warn(`[AntiCheat] Large purchase flagged from IP: ${ip} (¥${amount / 100})`); + return { suspicious: true, reason: 'large_amount' }; + } + + return { suspicious: false }; +} + +/** + * Validate game session stats for impossible values. + * @param {object} stats - { kills, timeElapsed, score, playerId } + * @returns {{ valid: boolean, flags: string[] }} + */ +function validateGameStats(stats) { + const flags = []; + + if (!stats) return { valid: true, flags }; + + // Impossible kill rate: >10 kills per minute + if (stats.kills && stats.timeElapsed) { + const killsPerMinute = stats.kills / (stats.timeElapsed / 60); + if (killsPerMinute > 10) { + flags.push('impossible_kill_rate'); + } + } + + // Impossible score + if (stats.score && stats.score > 100000) { + flags.push('impossible_score'); + } + + // Impossible speed (if movement data is provided) + if (stats.maxSpeed && stats.maxSpeed > 500) { + flags.push('impossible_speed'); + } + + if (flags.length > 0) { + console.warn(`[AntiCheat] Suspicious game stats from ${stats.playerId || 'unknown'}: ${flags.join(', ')}`); + } + + return { valid: flags.length === 0, flags }; +} + +// Clean up rate limit trackers periodically +setInterval(() => { + const now = Date.now(); + for (const [ip, tracker] of adRequestTracker) { + if (now > tracker.resetTime) { + adRequestTracker.delete(ip); + } + } + // Clean purchase tracker (entries older than 24h) + for (const [ip, history] of purchaseTracker) { + const cutoff = now - 24 * 60 * 60 * 1000; + while (history.length > 0 && history[0] < cutoff) { + history.shift(); + } + if (history.length === 0) { + purchaseTracker.delete(ip); + } + } +}, 300000); // Every 5 minutes + +// ============================================================ +// WebSocket Server +// ============================================================ +const wss = new WebSocketServer({ host: HOST, port: PORT }); + +wss.on('connection', (ws, req) => { + const ip = req.socket.remoteAddress; + console.log(`[Server] New connection from ${ip}`); + + const playerInfo = new PlayerInfo(ws, null); + players.set(ws, playerInfo); + + ws.on('message', (rawData) => { + handleMessage(ws, rawData.toString()); + }); + + ws.on('close', () => { + console.log(`[Server] Connection closed from ${ip}`); + handleTeamPlayerDisconnect(ws); + leaveRoom(ws); + players.delete(ws); + }); + + ws.on('error', (err) => { + console.error(`[Server] WebSocket error from ${ip}:`, err.message); + }); +}); + +// ============================================================ +// Heartbeat & Cleanup +// ============================================================ +setInterval(() => { + const now = Date.now(); + + // Clean up stale players + for (const [ws, info] of players) { + if (now - info.lastPing > HEARTBEAT_INTERVAL * 3) { + console.log(`[Server] Removing stale player ${info.playerId}`); + leaveRoom(ws); + players.delete(ws); + try { ws.terminate(); } catch (e) { /* ignore */ } + } + } + + // Clean up old empty rooms + for (const [id, room] of rooms) { + if (room.isEmpty() || now - room.createdAt > ROOM_TIMEOUT) { + rooms.delete(id); + console.log(`[Server] Room ${id} cleaned up`); + } + } + + // Clean up old team rooms + for (const [id, teamRoom] of teamRooms) { + const allHumans = teamRoom.getAllHumanMembers(); + const hasConnected = allHumans.some(m => m.ws && m.ws.readyState === 1); + if (!hasConnected || now - teamRoom.createdAt > ROOM_TIMEOUT * 2) { + cleanupTeamRoom(id); + console.log(`[Server] Team room ${id} cleaned up (stale)`); + } + } +}, HEARTBEAT_INTERVAL); + +// ============================================================ +// Startup +// ============================================================ +console.log(`[Tank War Server] Running on ${HOST}:${PORT}`); +console.log(`[Tank War Server] WebSocket URL: ws://${HOST}:${PORT}`); diff --git a/server/node_modules/.package-lock.json b/server/node_modules/.package-lock.json new file mode 100644 index 0000000..5488d4f --- /dev/null +++ b/server/node_modules/.package-lock.json @@ -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 + } + } + } + } +} diff --git a/server/node_modules/ws/LICENSE b/server/node_modules/ws/LICENSE new file mode 100644 index 0000000..1da5b96 --- /dev/null +++ b/server/node_modules/ws/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2011 Einar Otto Stangvik +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. diff --git a/server/node_modules/ws/README.md b/server/node_modules/ws/README.md new file mode 100644 index 0000000..21f10df --- /dev/null +++ b/server/node_modules/ws/README.md @@ -0,0 +1,548 @@ +# ws: a Node.js WebSocket library + +[![Version npm](https://img.shields.io/npm/v/ws.svg?logo=npm)](https://www.npmjs.com/package/ws) +[![CI](https://img.shields.io/github/actions/workflow/status/websockets/ws/ci.yml?branch=master&label=CI&logo=github)](https://github.com/websockets/ws/actions?query=workflow%3ACI+branch%3Amaster) +[![Coverage Status](https://img.shields.io/coveralls/websockets/ws/master.svg?logo=coveralls)](https://coveralls.io/github/websockets/ws) + +ws is a simple to use, blazing fast, and thoroughly tested WebSocket client and +server implementation. + +Passes the quite extensive Autobahn test suite: [server][server-report], +[client][client-report]. + +**Note**: This module does not work in the browser. The client in the docs is a +reference to a backend with the role of a client in the WebSocket communication. +Browser clients must use the native +[`WebSocket`](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) +object. To make the same code work seamlessly on Node.js and the browser, you +can use one of the many wrappers available on npm, like +[isomorphic-ws](https://github.com/heineiuo/isomorphic-ws). + +## Table of Contents + +- [Protocol support](#protocol-support) +- [Installing](#installing) + - [Opt-in for performance](#opt-in-for-performance) + - [Legacy opt-in for performance](#legacy-opt-in-for-performance) +- [API docs](#api-docs) +- [WebSocket compression](#websocket-compression) +- [Usage examples](#usage-examples) + - [Sending and receiving text data](#sending-and-receiving-text-data) + - [Sending binary data](#sending-binary-data) + - [Simple server](#simple-server) + - [External HTTP/S server](#external-https-server) + - [Multiple servers sharing a single HTTP/S server](#multiple-servers-sharing-a-single-https-server) + - [Client authentication](#client-authentication) + - [Server broadcast](#server-broadcast) + - [Round-trip time](#round-trip-time) + - [Use the Node.js streams API](#use-the-nodejs-streams-api) + - [Other examples](#other-examples) +- [FAQ](#faq) + - [How to get the IP address of the client?](#how-to-get-the-ip-address-of-the-client) + - [How to detect and close broken connections?](#how-to-detect-and-close-broken-connections) + - [How to connect via a proxy?](#how-to-connect-via-a-proxy) +- [Changelog](#changelog) +- [License](#license) + +## Protocol support + +- **HyBi drafts 07-12** (Use the option `protocolVersion: 8`) +- **HyBi drafts 13-17** (Current default, alternatively option + `protocolVersion: 13`) + +## Installing + +``` +npm install ws +``` + +### Opt-in for performance + +[bufferutil][] is an optional module that can be installed alongside the ws +module: + +``` +npm install --save-optional bufferutil +``` + +This is a binary addon that improves the performance of certain operations such +as masking and unmasking the data payload of the WebSocket frames. Prebuilt +binaries are available for the most popular platforms, so you don't necessarily +need to have a C++ compiler installed on your machine. + +To force ws to not use bufferutil, use the +[`WS_NO_BUFFER_UTIL`](./doc/ws.md#ws_no_buffer_util) environment variable. This +can be useful to enhance security in systems where a user can put a package in +the package search path of an application of another user, due to how the +Node.js resolver algorithm works. + +#### Legacy opt-in for performance + +If you are running on an old version of Node.js (prior to v18.14.0), ws also +supports the [utf-8-validate][] module: + +``` +npm install --save-optional utf-8-validate +``` + +This contains a binary polyfill for [`buffer.isUtf8()`][]. + +To force ws not to use utf-8-validate, use the +[`WS_NO_UTF_8_VALIDATE`](./doc/ws.md#ws_no_utf_8_validate) environment variable. + +## API docs + +See [`/doc/ws.md`](./doc/ws.md) for Node.js-like documentation of ws classes and +utility functions. + +## WebSocket compression + +ws supports the [permessage-deflate extension][permessage-deflate] which enables +the client and server to negotiate a compression algorithm and its parameters, +and then selectively apply it to the data payloads of each WebSocket message. + +The extension is disabled by default on the server and enabled by default on the +client. It adds a significant overhead in terms of performance and memory +consumption so we suggest to enable it only if it is really needed. + +Note that Node.js has a variety of issues with high-performance compression, +where increased concurrency, especially on Linux, can lead to [catastrophic +memory fragmentation][node-zlib-bug] and slow performance. If you intend to use +permessage-deflate in production, it is worthwhile to set up a test +representative of your workload and ensure Node.js/zlib will handle it with +acceptable performance and memory usage. + +Tuning of permessage-deflate can be done via the options defined below. You can +also use `zlibDeflateOptions` and `zlibInflateOptions`, which is passed directly +into the creation of [raw deflate/inflate streams][node-zlib-deflaterawdocs]. + +See [the docs][ws-server-options] for more options. + +```js +import WebSocket, { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ + port: 8080, + perMessageDeflate: { + zlibDeflateOptions: { + // See zlib defaults. + chunkSize: 1024, + memLevel: 7, + level: 3 + }, + zlibInflateOptions: { + chunkSize: 10 * 1024 + }, + // Other options settable: + clientNoContextTakeover: true, // Defaults to negotiated value. + serverNoContextTakeover: true, // Defaults to negotiated value. + serverMaxWindowBits: 10, // Defaults to negotiated value. + // Below options specified as default values. + concurrencyLimit: 10, // Limits zlib concurrency for perf. + threshold: 1024 // Size (in bytes) below which messages + // should not be compressed if context takeover is disabled. + } +}); +``` + +The client will only use the extension if it is supported and enabled on the +server. To always disable the extension on the client, set the +`perMessageDeflate` option to `false`. + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://www.host.com/path', { + perMessageDeflate: false +}); +``` + +## Usage examples + +### Sending and receiving text data + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://www.host.com/path'); + +ws.on('error', console.error); + +ws.on('open', function open() { + ws.send('something'); +}); + +ws.on('message', function message(data) { + console.log('received: %s', data); +}); +``` + +### Sending binary data + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('ws://www.host.com/path'); + +ws.on('error', console.error); + +ws.on('open', function open() { + const array = new Float32Array(5); + + for (var i = 0; i < array.length; ++i) { + array[i] = i / 2; + } + + ws.send(array); +}); +``` + +### Simple server + +```js +import { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.on('error', console.error); + + ws.on('message', function message(data) { + console.log('received: %s', data); + }); + + ws.send('something'); +}); +``` + +### External HTTP/S server + +```js +import { createServer } from 'https'; +import { readFileSync } from 'fs'; +import { WebSocketServer } from 'ws'; + +const server = createServer({ + cert: readFileSync('/path/to/cert.pem'), + key: readFileSync('/path/to/key.pem') +}); +const wss = new WebSocketServer({ server }); + +wss.on('connection', function connection(ws) { + ws.on('error', console.error); + + ws.on('message', function message(data) { + console.log('received: %s', data); + }); + + ws.send('something'); +}); + +server.listen(8080); +``` + +### Multiple servers sharing a single HTTP/S server + +```js +import { createServer } from 'http'; +import { WebSocketServer } from 'ws'; + +const server = createServer(); +const wss1 = new WebSocketServer({ noServer: true }); +const wss2 = new WebSocketServer({ noServer: true }); + +wss1.on('connection', function connection(ws) { + ws.on('error', console.error); + + // ... +}); + +wss2.on('connection', function connection(ws) { + ws.on('error', console.error); + + // ... +}); + +server.on('upgrade', function upgrade(request, socket, head) { + const { pathname } = new URL(request.url, 'wss://base.url'); + + if (pathname === '/foo') { + wss1.handleUpgrade(request, socket, head, function done(ws) { + wss1.emit('connection', ws, request); + }); + } else if (pathname === '/bar') { + wss2.handleUpgrade(request, socket, head, function done(ws) { + wss2.emit('connection', ws, request); + }); + } else { + socket.destroy(); + } +}); + +server.listen(8080); +``` + +### Client authentication + +```js +import { createServer } from 'http'; +import { WebSocketServer } from 'ws'; + +function onSocketError(err) { + console.error(err); +} + +const server = createServer(); +const wss = new WebSocketServer({ noServer: true }); + +wss.on('connection', function connection(ws, request, client) { + ws.on('error', console.error); + + ws.on('message', function message(data) { + console.log(`Received message ${data} from user ${client}`); + }); +}); + +server.on('upgrade', function upgrade(request, socket, head) { + socket.on('error', onSocketError); + + // This function is not defined on purpose. Implement it with your own logic. + authenticate(request, function next(err, client) { + if (err || !client) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + socket.destroy(); + return; + } + + socket.removeListener('error', onSocketError); + + wss.handleUpgrade(request, socket, head, function done(ws) { + wss.emit('connection', ws, request, client); + }); + }); +}); + +server.listen(8080); +``` + +Also see the provided [example][session-parse-example] using `express-session`. + +### Server broadcast + +A client WebSocket broadcasting to all connected WebSocket clients, including +itself. + +```js +import WebSocket, { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.on('error', console.error); + + ws.on('message', function message(data, isBinary) { + wss.clients.forEach(function each(client) { + if (client.readyState === WebSocket.OPEN) { + client.send(data, { binary: isBinary }); + } + }); + }); +}); +``` + +A client WebSocket broadcasting to every other connected WebSocket clients, +excluding itself. + +```js +import WebSocket, { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.on('error', console.error); + + ws.on('message', function message(data, isBinary) { + wss.clients.forEach(function each(client) { + if (client !== ws && client.readyState === WebSocket.OPEN) { + client.send(data, { binary: isBinary }); + } + }); + }); +}); +``` + +### Round-trip time + +```js +import WebSocket from 'ws'; + +const ws = new WebSocket('wss://websocket-echo.com/'); + +ws.on('error', console.error); + +ws.on('open', function open() { + console.log('connected'); + ws.send(Date.now()); +}); + +ws.on('close', function close() { + console.log('disconnected'); +}); + +ws.on('message', function message(data) { + console.log(`Round-trip time: ${Date.now() - data} ms`); + + setTimeout(function timeout() { + ws.send(Date.now()); + }, 500); +}); +``` + +### Use the Node.js streams API + +```js +import WebSocket, { createWebSocketStream } from 'ws'; + +const ws = new WebSocket('wss://websocket-echo.com/'); + +const duplex = createWebSocketStream(ws, { encoding: 'utf8' }); + +duplex.on('error', console.error); + +duplex.pipe(process.stdout); +process.stdin.pipe(duplex); +``` + +### Other examples + +For a full example with a browser client communicating with a ws server, see the +examples folder. + +Otherwise, see the test cases. + +## FAQ + +### How to get the IP address of the client? + +The remote IP address can be obtained from the raw socket. + +```js +import { WebSocketServer } from 'ws'; + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws, req) { + const ip = req.socket.remoteAddress; + + ws.on('error', console.error); +}); +``` + +When the server runs behind a proxy like NGINX, the de-facto standard is to use +the `X-Forwarded-For` header. + +```js +wss.on('connection', function connection(ws, req) { + const ip = req.headers['x-forwarded-for'].split(',')[0].trim(); + + ws.on('error', console.error); +}); +``` + +### How to detect and close broken connections? + +Sometimes, the link between the server and the client can be interrupted in a +way that keeps both the server and the client unaware of the broken state of the +connection (e.g. when pulling the cord). + +In these cases, ping messages can be used as a means to verify that the remote +endpoint is still responsive. + +```js +import { WebSocketServer } from 'ws'; + +function heartbeat() { + this.isAlive = true; +} + +const wss = new WebSocketServer({ port: 8080 }); + +wss.on('connection', function connection(ws) { + ws.isAlive = true; + ws.on('error', console.error); + ws.on('pong', heartbeat); +}); + +const interval = setInterval(function ping() { + wss.clients.forEach(function each(ws) { + if (ws.isAlive === false) return ws.terminate(); + + ws.isAlive = false; + ws.ping(); + }); +}, 30000); + +wss.on('close', function close() { + clearInterval(interval); +}); +``` + +Pong messages are automatically sent in response to ping messages as required by +the spec. + +Just like the server example above, your clients might as well lose connection +without knowing it. You might want to add a ping listener on your clients to +prevent that. A simple implementation would be: + +```js +import WebSocket from 'ws'; + +function heartbeat() { + clearTimeout(this.pingTimeout); + + // Use `WebSocket#terminate()`, which immediately destroys the connection, + // instead of `WebSocket#close()`, which waits for the close timer. + // Delay should be equal to the interval at which your server + // sends out pings plus a conservative assumption of the latency. + this.pingTimeout = setTimeout(() => { + this.terminate(); + }, 30000 + 1000); +} + +const client = new WebSocket('wss://websocket-echo.com/'); + +client.on('error', console.error); +client.on('open', heartbeat); +client.on('ping', heartbeat); +client.on('close', function clear() { + clearTimeout(this.pingTimeout); +}); +``` + +### How to connect via a proxy? + +Use a custom `http.Agent` implementation like [https-proxy-agent][] or +[socks-proxy-agent][]. + +## Changelog + +We're using the GitHub [releases][changelog] for changelog entries. + +## License + +[MIT](LICENSE) + +[`buffer.isutf8()`]: https://nodejs.org/api/buffer.html#bufferisutf8input +[bufferutil]: https://github.com/websockets/bufferutil +[changelog]: https://github.com/websockets/ws/releases +[client-report]: http://websockets.github.io/ws/autobahn/clients/ +[https-proxy-agent]: https://github.com/TooTallNate/node-https-proxy-agent +[node-zlib-bug]: https://github.com/nodejs/node/issues/8871 +[node-zlib-deflaterawdocs]: + https://nodejs.org/api/zlib.html#zlib_zlib_createdeflateraw_options +[permessage-deflate]: https://tools.ietf.org/html/rfc7692 +[server-report]: http://websockets.github.io/ws/autobahn/servers/ +[session-parse-example]: ./examples/express-session-parse +[socks-proxy-agent]: https://github.com/TooTallNate/node-socks-proxy-agent +[utf-8-validate]: https://github.com/websockets/utf-8-validate +[ws-server-options]: ./doc/ws.md#new-websocketserveroptions-callback diff --git a/server/node_modules/ws/browser.js b/server/node_modules/ws/browser.js new file mode 100644 index 0000000..ca4f628 --- /dev/null +++ b/server/node_modules/ws/browser.js @@ -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' + ); +}; diff --git a/server/node_modules/ws/index.js b/server/node_modules/ws/index.js new file mode 100644 index 0000000..3fdb7b2 --- /dev/null +++ b/server/node_modules/ws/index.js @@ -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; diff --git a/server/node_modules/ws/lib/buffer-util.js b/server/node_modules/ws/lib/buffer-util.js new file mode 100644 index 0000000..f7536e2 --- /dev/null +++ b/server/node_modules/ws/lib/buffer-util.js @@ -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. + } +} diff --git a/server/node_modules/ws/lib/constants.js b/server/node_modules/ws/lib/constants.js new file mode 100644 index 0000000..69b2fe3 --- /dev/null +++ b/server/node_modules/ws/lib/constants.js @@ -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: () => {} +}; diff --git a/server/node_modules/ws/lib/event-target.js b/server/node_modules/ws/lib/event-target.js new file mode 100644 index 0000000..fea4cbc --- /dev/null +++ b/server/node_modules/ws/lib/event-target.js @@ -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); + } +} diff --git a/server/node_modules/ws/lib/extension.js b/server/node_modules/ws/lib/extension.js new file mode 100644 index 0000000..3d7895c --- /dev/null +++ b/server/node_modules/ws/lib/extension.js @@ -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 }; diff --git a/server/node_modules/ws/lib/limiter.js b/server/node_modules/ws/lib/limiter.js new file mode 100644 index 0000000..3fd3578 --- /dev/null +++ b/server/node_modules/ws/lib/limiter.js @@ -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; diff --git a/server/node_modules/ws/lib/permessage-deflate.js b/server/node_modules/ws/lib/permessage-deflate.js new file mode 100644 index 0000000..aa5db76 --- /dev/null +++ b/server/node_modules/ws/lib/permessage-deflate.js @@ -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); +} diff --git a/server/node_modules/ws/lib/receiver.js b/server/node_modules/ws/lib/receiver.js new file mode 100644 index 0000000..54d9b4f --- /dev/null +++ b/server/node_modules/ws/lib/receiver.js @@ -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; diff --git a/server/node_modules/ws/lib/sender.js b/server/node_modules/ws/lib/sender.js new file mode 100644 index 0000000..a8b1da3 --- /dev/null +++ b/server/node_modules/ws/lib/sender.js @@ -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); +} diff --git a/server/node_modules/ws/lib/stream.js b/server/node_modules/ws/lib/stream.js new file mode 100644 index 0000000..4c58c91 --- /dev/null +++ b/server/node_modules/ws/lib/stream.js @@ -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; diff --git a/server/node_modules/ws/lib/subprotocol.js b/server/node_modules/ws/lib/subprotocol.js new file mode 100644 index 0000000..d4381e8 --- /dev/null +++ b/server/node_modules/ws/lib/subprotocol.js @@ -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 }; diff --git a/server/node_modules/ws/lib/validation.js b/server/node_modules/ws/lib/validation.js new file mode 100644 index 0000000..4a2e68d --- /dev/null +++ b/server/node_modules/ws/lib/validation.js @@ -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. + } +} diff --git a/server/node_modules/ws/lib/websocket-server.js b/server/node_modules/ws/lib/websocket-server.js new file mode 100644 index 0000000..68aa789 --- /dev/null +++ b/server/node_modules/ws/lib/websocket-server.js @@ -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 + * pairs. + * + * @param {EventEmitter} server The event emitter + * @param {Object.} 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); + } +} diff --git a/server/node_modules/ws/lib/websocket.js b/server/node_modules/ws/lib/websocket.js new file mode 100644 index 0000000..75d5bb2 --- /dev/null +++ b/server/node_modules/ws/lib/websocket.js @@ -0,0 +1,1393 @@ +/* eslint no-unused-vars: ["error", { "varsIgnorePattern": "^Duplex|Readable$", "caughtErrors": "none" }] */ + +'use strict'; + +const EventEmitter = require('events'); +const https = require('https'); +const http = require('http'); +const net = require('net'); +const tls = require('tls'); +const { randomBytes, createHash } = require('crypto'); +const { Duplex, Readable } = require('stream'); +const { URL } = require('url'); + +const PerMessageDeflate = require('./permessage-deflate'); +const Receiver = require('./receiver'); +const Sender = require('./sender'); +const { isBlob } = require('./validation'); + +const { + BINARY_TYPES, + CLOSE_TIMEOUT, + EMPTY_BUFFER, + GUID, + kForOnEventAttribute, + kListener, + kStatusCode, + kWebSocket, + NOOP +} = require('./constants'); +const { + EventTarget: { addEventListener, removeEventListener } +} = require('./event-target'); +const { format, parse } = require('./extension'); +const { toBuffer } = require('./buffer-util'); + +const kAborted = Symbol('kAborted'); +const protocolVersions = [8, 13]; +const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; +const subprotocolRegex = /^[!#$%&'*+\-.0-9A-Z^_`|a-z~]+$/; + +/** + * Class representing a WebSocket. + * + * @extends EventEmitter + */ +class WebSocket extends EventEmitter { + /** + * Create a new `WebSocket`. + * + * @param {(String|URL)} address The URL to which to connect + * @param {(String|String[])} [protocols] The subprotocols + * @param {Object} [options] Connection options + */ + constructor(address, protocols, options) { + super(); + + this._binaryType = BINARY_TYPES[0]; + this._closeCode = 1006; + this._closeFrameReceived = false; + this._closeFrameSent = false; + this._closeMessage = EMPTY_BUFFER; + this._closeTimer = null; + this._errorEmitted = false; + this._extensions = {}; + this._paused = false; + this._protocol = ''; + this._readyState = WebSocket.CONNECTING; + this._receiver = null; + this._sender = null; + this._socket = null; + + if (address !== null) { + this._bufferedAmount = 0; + this._isServer = false; + this._redirects = 0; + + if (protocols === undefined) { + protocols = []; + } else if (!Array.isArray(protocols)) { + if (typeof protocols === 'object' && protocols !== null) { + options = protocols; + protocols = []; + } else { + protocols = [protocols]; + } + } + + initAsClient(this, address, protocols, options); + } else { + this._autoPong = options.autoPong; + this._closeTimeout = options.closeTimeout; + this._isServer = true; + } + } + + /** + * For historical reasons, the custom "nodebuffer" type is used by the default + * instead of "blob". + * + * @type {String} + */ + get binaryType() { + return this._binaryType; + } + + set binaryType(type) { + if (!BINARY_TYPES.includes(type)) return; + + this._binaryType = type; + + // + // Allow to change `binaryType` on the fly. + // + if (this._receiver) this._receiver._binaryType = type; + } + + /** + * @type {Number} + */ + get bufferedAmount() { + if (!this._socket) return this._bufferedAmount; + + return this._socket._writableState.length + this._sender._bufferedBytes; + } + + /** + * @type {String} + */ + get extensions() { + return Object.keys(this._extensions).join(); + } + + /** + * @type {Boolean} + */ + get isPaused() { + return this._paused; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onclose() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onerror() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onopen() { + return null; + } + + /** + * @type {Function} + */ + /* istanbul ignore next */ + get onmessage() { + return null; + } + + /** + * @type {String} + */ + get protocol() { + return this._protocol; + } + + /** + * @type {Number} + */ + get readyState() { + return this._readyState; + } + + /** + * @type {String} + */ + get url() { + return this._url; + } + + /** + * Set up the socket and the internal resources. + * + * @param {Duplex} socket The network socket between the server and client + * @param {Buffer} head The first packet of the upgraded stream + * @param {Object} options Options object + * @param {Boolean} [options.allowSynchronousEvents=false] Specifies whether + * any of the `'message'`, `'ping'`, and `'pong'` events can be emitted + * multiple times in the same tick + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.maxPayload=0] The maximum allowed message size + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ + setSocket(socket, head, options) { + const receiver = new Receiver({ + allowSynchronousEvents: options.allowSynchronousEvents, + binaryType: this.binaryType, + extensions: this._extensions, + isServer: this._isServer, + maxPayload: options.maxPayload, + skipUTF8Validation: options.skipUTF8Validation + }); + + const sender = new Sender(socket, this._extensions, options.generateMask); + + this._receiver = receiver; + this._sender = sender; + this._socket = socket; + + receiver[kWebSocket] = this; + sender[kWebSocket] = this; + socket[kWebSocket] = this; + + receiver.on('conclude', receiverOnConclude); + receiver.on('drain', receiverOnDrain); + receiver.on('error', receiverOnError); + receiver.on('message', receiverOnMessage); + receiver.on('ping', receiverOnPing); + receiver.on('pong', receiverOnPong); + + sender.onerror = senderOnError; + + // + // These methods may not be available if `socket` is just a `Duplex`. + // + if (socket.setTimeout) socket.setTimeout(0); + if (socket.setNoDelay) socket.setNoDelay(); + + if (head.length > 0) socket.unshift(head); + + socket.on('close', socketOnClose); + socket.on('data', socketOnData); + socket.on('end', socketOnEnd); + socket.on('error', socketOnError); + + this._readyState = WebSocket.OPEN; + this.emit('open'); + } + + /** + * Emit the `'close'` event. + * + * @private + */ + emitClose() { + if (!this._socket) { + this._readyState = WebSocket.CLOSED; + this.emit('close', this._closeCode, this._closeMessage); + return; + } + + if (this._extensions[PerMessageDeflate.extensionName]) { + this._extensions[PerMessageDeflate.extensionName].cleanup(); + } + + this._receiver.removeAllListeners(); + this._readyState = WebSocket.CLOSED; + this.emit('close', this._closeCode, this._closeMessage); + } + + /** + * Start a closing handshake. + * + * +----------+ +-----------+ +----------+ + * - - -|ws.close()|-->|close frame|-->|ws.close()|- - - + * | +----------+ +-----------+ +----------+ | + * +----------+ +-----------+ | + * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING + * +----------+ +-----------+ | + * | | | +---+ | + * +------------------------+-->|fin| - - - - + * | +---+ | +---+ + * - - - - -|fin|<---------------------+ + * +---+ + * + * @param {Number} [code] Status code explaining why the connection is closing + * @param {(String|Buffer)} [data] The reason why the connection is + * closing + * @public + */ + close(code, data) { + if (this.readyState === WebSocket.CLOSED) return; + if (this.readyState === WebSocket.CONNECTING) { + const msg = 'WebSocket was closed before the connection was established'; + abortHandshake(this, this._req, msg); + return; + } + + if (this.readyState === WebSocket.CLOSING) { + if ( + this._closeFrameSent && + (this._closeFrameReceived || this._receiver._writableState.errorEmitted) + ) { + this._socket.end(); + } + + return; + } + + this._readyState = WebSocket.CLOSING; + this._sender.close(code, data, !this._isServer, (err) => { + // + // This error is handled by the `'error'` listener on the socket. We only + // want to know if the close frame has been sent here. + // + if (err) return; + + this._closeFrameSent = true; + + if ( + this._closeFrameReceived || + this._receiver._writableState.errorEmitted + ) { + this._socket.end(); + } + }); + + setCloseTimer(this); + } + + /** + * Pause the socket. + * + * @public + */ + pause() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = true; + this._socket.pause(); + } + + /** + * Send a ping. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the ping is sent + * @public + */ + ping(data, mask, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof data === 'function') { + cb = data; + data = mask = undefined; + } else if (typeof mask === 'function') { + cb = mask; + mask = undefined; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + if (mask === undefined) mask = !this._isServer; + this._sender.ping(data || EMPTY_BUFFER, mask, cb); + } + + /** + * Send a pong. + * + * @param {*} [data] The data to send + * @param {Boolean} [mask] Indicates whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when the pong is sent + * @public + */ + pong(data, mask, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof data === 'function') { + cb = data; + data = mask = undefined; + } else if (typeof mask === 'function') { + cb = mask; + mask = undefined; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + if (mask === undefined) mask = !this._isServer; + this._sender.pong(data || EMPTY_BUFFER, mask, cb); + } + + /** + * Resume the socket. + * + * @public + */ + resume() { + if ( + this.readyState === WebSocket.CONNECTING || + this.readyState === WebSocket.CLOSED + ) { + return; + } + + this._paused = false; + if (!this._receiver._writableState.needDrain) this._socket.resume(); + } + + /** + * Send a data message. + * + * @param {*} data The message to send + * @param {Object} [options] Options object + * @param {Boolean} [options.binary] Specifies whether `data` is binary or + * text + * @param {Boolean} [options.compress] Specifies whether or not to compress + * `data` + * @param {Boolean} [options.fin=true] Specifies whether the fragment is the + * last one + * @param {Boolean} [options.mask] Specifies whether or not to mask `data` + * @param {Function} [cb] Callback which is executed when data is written out + * @public + */ + send(data, options, cb) { + if (this.readyState === WebSocket.CONNECTING) { + throw new Error('WebSocket is not open: readyState 0 (CONNECTING)'); + } + + if (typeof options === 'function') { + cb = options; + options = {}; + } + + if (typeof data === 'number') data = data.toString(); + + if (this.readyState !== WebSocket.OPEN) { + sendAfterClose(this, data, cb); + return; + } + + const opts = { + binary: typeof data !== 'string', + mask: !this._isServer, + compress: true, + fin: true, + ...options + }; + + if (!this._extensions[PerMessageDeflate.extensionName]) { + opts.compress = false; + } + + this._sender.send(data || EMPTY_BUFFER, opts, cb); + } + + /** + * Forcibly close the connection. + * + * @public + */ + terminate() { + if (this.readyState === WebSocket.CLOSED) return; + if (this.readyState === WebSocket.CONNECTING) { + const msg = 'WebSocket was closed before the connection was established'; + abortHandshake(this, this._req, msg); + return; + } + + if (this._socket) { + this._readyState = WebSocket.CLOSING; + this._socket.destroy(); + } + } +} + +/** + * @constant {Number} CONNECTING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} CONNECTING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CONNECTING', { + enumerable: true, + value: readyStates.indexOf('CONNECTING') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} OPEN + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'OPEN', { + enumerable: true, + value: readyStates.indexOf('OPEN') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSING + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSING', { + enumerable: true, + value: readyStates.indexOf('CLOSING') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket + */ +Object.defineProperty(WebSocket, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +/** + * @constant {Number} CLOSED + * @memberof WebSocket.prototype + */ +Object.defineProperty(WebSocket.prototype, 'CLOSED', { + enumerable: true, + value: readyStates.indexOf('CLOSED') +}); + +[ + 'binaryType', + 'bufferedAmount', + 'extensions', + 'isPaused', + 'protocol', + 'readyState', + 'url' +].forEach((property) => { + Object.defineProperty(WebSocket.prototype, property, { enumerable: true }); +}); + +// +// Add the `onopen`, `onerror`, `onclose`, and `onmessage` attributes. +// See https://html.spec.whatwg.org/multipage/comms.html#the-websocket-interface +// +['open', 'error', 'close', 'message'].forEach((method) => { + Object.defineProperty(WebSocket.prototype, `on${method}`, { + enumerable: true, + get() { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) return listener[kListener]; + } + + return null; + }, + set(handler) { + for (const listener of this.listeners(method)) { + if (listener[kForOnEventAttribute]) { + this.removeListener(method, listener); + break; + } + } + + if (typeof handler !== 'function') return; + + this.addEventListener(method, handler, { + [kForOnEventAttribute]: true + }); + } + }); +}); + +WebSocket.prototype.addEventListener = addEventListener; +WebSocket.prototype.removeEventListener = removeEventListener; + +module.exports = WebSocket; + +/** + * Initialize a WebSocket client. + * + * @param {WebSocket} websocket The client to initialize + * @param {(String|URL)} address The URL to which to connect + * @param {Array} protocols The subprotocols + * @param {Object} [options] Connection 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.closeTimeout=30000] Duration in milliseconds to wait + * for the closing handshake to finish after `websocket.close()` is called + * @param {Function} [options.finishRequest] A function which can be used to + * customize the headers of each http request before it is sent + * @param {Boolean} [options.followRedirects=false] Whether or not to follow + * redirects + * @param {Function} [options.generateMask] The function used to generate the + * masking key + * @param {Number} [options.handshakeTimeout] Timeout in milliseconds for the + * handshake request + * @param {Number} [options.maxPayload=104857600] The maximum allowed message + * size + * @param {Number} [options.maxRedirects=10] The maximum number of redirects + * allowed + * @param {String} [options.origin] Value of the `Origin` or + * `Sec-WebSocket-Origin` header + * @param {(Boolean|Object)} [options.perMessageDeflate=true] Enable/disable + * permessage-deflate + * @param {Number} [options.protocolVersion=13] Value of the + * `Sec-WebSocket-Version` header + * @param {Boolean} [options.skipUTF8Validation=false] Specifies whether or + * not to skip UTF-8 validation for text and close messages + * @private + */ +function initAsClient(websocket, address, protocols, options) { + const opts = { + allowSynchronousEvents: true, + autoPong: true, + closeTimeout: CLOSE_TIMEOUT, + protocolVersion: protocolVersions[1], + maxPayload: 100 * 1024 * 1024, + skipUTF8Validation: false, + perMessageDeflate: true, + followRedirects: false, + maxRedirects: 10, + ...options, + socketPath: undefined, + hostname: undefined, + protocol: undefined, + timeout: undefined, + method: 'GET', + host: undefined, + path: undefined, + port: undefined + }; + + websocket._autoPong = opts.autoPong; + websocket._closeTimeout = opts.closeTimeout; + + if (!protocolVersions.includes(opts.protocolVersion)) { + throw new RangeError( + `Unsupported protocol version: ${opts.protocolVersion} ` + + `(supported versions: ${protocolVersions.join(', ')})` + ); + } + + let parsedUrl; + + if (address instanceof URL) { + parsedUrl = address; + } else { + try { + parsedUrl = new URL(address); + } catch { + throw new SyntaxError(`Invalid URL: ${address}`); + } + } + + if (parsedUrl.protocol === 'http:') { + parsedUrl.protocol = 'ws:'; + } else if (parsedUrl.protocol === 'https:') { + parsedUrl.protocol = 'wss:'; + } + + websocket._url = parsedUrl.href; + + const isSecure = parsedUrl.protocol === 'wss:'; + const isIpcUrl = parsedUrl.protocol === 'ws+unix:'; + let invalidUrlMessage; + + if (parsedUrl.protocol !== 'ws:' && !isSecure && !isIpcUrl) { + invalidUrlMessage = + 'The URL\'s protocol must be one of "ws:", "wss:", ' + + '"http:", "https:", or "ws+unix:"'; + } else if (isIpcUrl && !parsedUrl.pathname) { + invalidUrlMessage = "The URL's pathname is empty"; + } else if (parsedUrl.hash) { + invalidUrlMessage = 'The URL contains a fragment identifier'; + } + + if (invalidUrlMessage) { + const err = new SyntaxError(invalidUrlMessage); + + if (websocket._redirects === 0) { + throw err; + } else { + emitErrorAndClose(websocket, err); + return; + } + } + + const defaultPort = isSecure ? 443 : 80; + const key = randomBytes(16).toString('base64'); + const request = isSecure ? https.request : http.request; + const protocolSet = new Set(); + let perMessageDeflate; + + opts.createConnection = + opts.createConnection || (isSecure ? tlsConnect : netConnect); + opts.defaultPort = opts.defaultPort || defaultPort; + opts.port = parsedUrl.port || defaultPort; + opts.host = parsedUrl.hostname.startsWith('[') + ? parsedUrl.hostname.slice(1, -1) + : parsedUrl.hostname; + opts.headers = { + ...opts.headers, + 'Sec-WebSocket-Version': opts.protocolVersion, + 'Sec-WebSocket-Key': key, + Connection: 'Upgrade', + Upgrade: 'websocket' + }; + opts.path = parsedUrl.pathname + parsedUrl.search; + opts.timeout = opts.handshakeTimeout; + + if (opts.perMessageDeflate) { + perMessageDeflate = new PerMessageDeflate({ + ...opts.perMessageDeflate, + isServer: false, + maxPayload: opts.maxPayload + }); + opts.headers['Sec-WebSocket-Extensions'] = format({ + [PerMessageDeflate.extensionName]: perMessageDeflate.offer() + }); + } + if (protocols.length) { + for (const protocol of protocols) { + if ( + typeof protocol !== 'string' || + !subprotocolRegex.test(protocol) || + protocolSet.has(protocol) + ) { + throw new SyntaxError( + 'An invalid or duplicated subprotocol was specified' + ); + } + + protocolSet.add(protocol); + } + + opts.headers['Sec-WebSocket-Protocol'] = protocols.join(','); + } + if (opts.origin) { + if (opts.protocolVersion < 13) { + opts.headers['Sec-WebSocket-Origin'] = opts.origin; + } else { + opts.headers.Origin = opts.origin; + } + } + if (parsedUrl.username || parsedUrl.password) { + opts.auth = `${parsedUrl.username}:${parsedUrl.password}`; + } + + if (isIpcUrl) { + const parts = opts.path.split(':'); + + opts.socketPath = parts[0]; + opts.path = parts[1]; + } + + let req; + + if (opts.followRedirects) { + if (websocket._redirects === 0) { + websocket._originalIpc = isIpcUrl; + websocket._originalSecure = isSecure; + websocket._originalHostOrSocketPath = isIpcUrl + ? opts.socketPath + : parsedUrl.host; + + const headers = options && options.headers; + + // + // Shallow copy the user provided options so that headers can be changed + // without mutating the original object. + // + options = { ...options, headers: {} }; + + if (headers) { + for (const [key, value] of Object.entries(headers)) { + options.headers[key.toLowerCase()] = value; + } + } + } else if (websocket.listenerCount('redirect') === 0) { + const isSameHost = isIpcUrl + ? websocket._originalIpc + ? opts.socketPath === websocket._originalHostOrSocketPath + : false + : websocket._originalIpc + ? false + : parsedUrl.host === websocket._originalHostOrSocketPath; + + if (!isSameHost || (websocket._originalSecure && !isSecure)) { + // + // Match curl 7.77.0 behavior and drop the following headers. These + // headers are also dropped when following a redirect to a subdomain. + // + delete opts.headers.authorization; + delete opts.headers.cookie; + + if (!isSameHost) delete opts.headers.host; + + opts.auth = undefined; + } + } + + // + // Match curl 7.77.0 behavior and make the first `Authorization` header win. + // If the `Authorization` header is set, then there is nothing to do as it + // will take precedence. + // + if (opts.auth && !options.headers.authorization) { + options.headers.authorization = + 'Basic ' + Buffer.from(opts.auth).toString('base64'); + } + + req = websocket._req = request(opts); + + if (websocket._redirects) { + // + // Unlike what is done for the `'upgrade'` event, no early exit is + // triggered here if the user calls `websocket.close()` or + // `websocket.terminate()` from a listener of the `'redirect'` event. This + // is because the user can also call `request.destroy()` with an error + // before calling `websocket.close()` or `websocket.terminate()` and this + // would result in an error being emitted on the `request` object with no + // `'error'` event listeners attached. + // + websocket.emit('redirect', websocket.url, req); + } + } else { + req = websocket._req = request(opts); + } + + if (opts.timeout) { + req.on('timeout', () => { + abortHandshake(websocket, req, 'Opening handshake has timed out'); + }); + } + + req.on('error', (err) => { + if (req === null || req[kAborted]) return; + + req = websocket._req = null; + emitErrorAndClose(websocket, err); + }); + + req.on('response', (res) => { + const location = res.headers.location; + const statusCode = res.statusCode; + + if ( + location && + opts.followRedirects && + statusCode >= 300 && + statusCode < 400 + ) { + if (++websocket._redirects > opts.maxRedirects) { + abortHandshake(websocket, req, 'Maximum redirects exceeded'); + return; + } + + req.abort(); + + let addr; + + try { + addr = new URL(location, address); + } catch (e) { + const err = new SyntaxError(`Invalid URL: ${location}`); + emitErrorAndClose(websocket, err); + return; + } + + initAsClient(websocket, addr, protocols, options); + } else if (!websocket.emit('unexpected-response', req, res)) { + abortHandshake( + websocket, + req, + `Unexpected server response: ${res.statusCode}` + ); + } + }); + + req.on('upgrade', (res, socket, head) => { + websocket.emit('upgrade', res); + + // + // The user may have closed the connection from a listener of the + // `'upgrade'` event. + // + if (websocket.readyState !== WebSocket.CONNECTING) return; + + req = websocket._req = null; + + const upgrade = res.headers.upgrade; + + if (upgrade === undefined || upgrade.toLowerCase() !== 'websocket') { + abortHandshake(websocket, socket, 'Invalid Upgrade header'); + return; + } + + const digest = createHash('sha1') + .update(key + GUID) + .digest('base64'); + + if (res.headers['sec-websocket-accept'] !== digest) { + abortHandshake(websocket, socket, 'Invalid Sec-WebSocket-Accept header'); + return; + } + + const serverProt = res.headers['sec-websocket-protocol']; + let protError; + + if (serverProt !== undefined) { + if (!protocolSet.size) { + protError = 'Server sent a subprotocol but none was requested'; + } else if (!protocolSet.has(serverProt)) { + protError = 'Server sent an invalid subprotocol'; + } + } else if (protocolSet.size) { + protError = 'Server sent no subprotocol'; + } + + if (protError) { + abortHandshake(websocket, socket, protError); + return; + } + + if (serverProt) websocket._protocol = serverProt; + + const secWebSocketExtensions = res.headers['sec-websocket-extensions']; + + if (secWebSocketExtensions !== undefined) { + if (!perMessageDeflate) { + const message = + 'Server sent a Sec-WebSocket-Extensions header but no extension ' + + 'was requested'; + abortHandshake(websocket, socket, message); + return; + } + + let extensions; + + try { + extensions = parse(secWebSocketExtensions); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; + } + + const extensionNames = Object.keys(extensions); + + if ( + extensionNames.length !== 1 || + extensionNames[0] !== PerMessageDeflate.extensionName + ) { + const message = 'Server indicated an extension that was not requested'; + abortHandshake(websocket, socket, message); + return; + } + + try { + perMessageDeflate.accept(extensions[PerMessageDeflate.extensionName]); + } catch (err) { + const message = 'Invalid Sec-WebSocket-Extensions header'; + abortHandshake(websocket, socket, message); + return; + } + + websocket._extensions[PerMessageDeflate.extensionName] = + perMessageDeflate; + } + + websocket.setSocket(socket, head, { + allowSynchronousEvents: opts.allowSynchronousEvents, + generateMask: opts.generateMask, + maxPayload: opts.maxPayload, + skipUTF8Validation: opts.skipUTF8Validation + }); + }); + + if (opts.finishRequest) { + opts.finishRequest(req, websocket); + } else { + req.end(); + } +} + +/** + * Emit the `'error'` and `'close'` events. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {Error} The error to emit + * @private + */ +function emitErrorAndClose(websocket, err) { + websocket._readyState = WebSocket.CLOSING; + // + // The following assignment is practically useless and is done only for + // consistency. + // + websocket._errorEmitted = true; + websocket.emit('error', err); + websocket.emitClose(); +} + +/** + * Create a `net.Socket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {net.Socket} The newly created socket used to start the connection + * @private + */ +function netConnect(options) { + options.path = options.socketPath; + return net.connect(options); +} + +/** + * Create a `tls.TLSSocket` and initiate a connection. + * + * @param {Object} options Connection options + * @return {tls.TLSSocket} The newly created socket used to start the connection + * @private + */ +function tlsConnect(options) { + options.path = undefined; + + if (!options.servername && options.servername !== '') { + options.servername = net.isIP(options.host) ? '' : options.host; + } + + return tls.connect(options); +} + +/** + * Abort the handshake and emit an error. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {(http.ClientRequest|net.Socket|tls.Socket)} stream The request to + * abort or the socket to destroy + * @param {String} message The error message + * @private + */ +function abortHandshake(websocket, stream, message) { + websocket._readyState = WebSocket.CLOSING; + + const err = new Error(message); + Error.captureStackTrace(err, abortHandshake); + + if (stream.setHeader) { + stream[kAborted] = true; + stream.abort(); + + if (stream.socket && !stream.socket.destroyed) { + // + // On Node.js >= 14.3.0 `request.abort()` does not destroy the socket if + // called after the request completed. See + // https://github.com/websockets/ws/issues/1869. + // + stream.socket.destroy(); + } + + process.nextTick(emitErrorAndClose, websocket, err); + } else { + stream.destroy(err); + stream.once('error', websocket.emit.bind(websocket, 'error')); + stream.once('close', websocket.emitClose.bind(websocket)); + } +} + +/** + * Handle cases where the `ping()`, `pong()`, or `send()` methods are called + * when the `readyState` attribute is `CLOSING` or `CLOSED`. + * + * @param {WebSocket} websocket The WebSocket instance + * @param {*} [data] The data to send + * @param {Function} [cb] Callback + * @private + */ +function sendAfterClose(websocket, data, cb) { + if (data) { + const length = isBlob(data) ? data.size : toBuffer(data).length; + + // + // The `_bufferedAmount` property is used only when the peer is a client and + // the opening handshake fails. Under these circumstances, in fact, the + // `setSocket()` method is not called, so the `_socket` and `_sender` + // properties are set to `null`. + // + if (websocket._socket) websocket._sender._bufferedBytes += length; + else websocket._bufferedAmount += length; + } + + if (cb) { + const err = new Error( + `WebSocket is not open: readyState ${websocket.readyState} ` + + `(${readyStates[websocket.readyState]})` + ); + process.nextTick(cb, err); + } +} + +/** + * The listener of the `Receiver` `'conclude'` event. + * + * @param {Number} code The status code + * @param {Buffer} reason The reason for closing + * @private + */ +function receiverOnConclude(code, reason) { + const websocket = this[kWebSocket]; + + websocket._closeFrameReceived = true; + websocket._closeMessage = reason; + websocket._closeCode = code; + + if (websocket._socket[kWebSocket] === undefined) return; + + websocket._socket.removeListener('data', socketOnData); + process.nextTick(resume, websocket._socket); + + if (code === 1005) websocket.close(); + else websocket.close(code, reason); +} + +/** + * The listener of the `Receiver` `'drain'` event. + * + * @private + */ +function receiverOnDrain() { + const websocket = this[kWebSocket]; + + if (!websocket.isPaused) websocket._socket.resume(); +} + +/** + * The listener of the `Receiver` `'error'` event. + * + * @param {(RangeError|Error)} err The emitted error + * @private + */ +function receiverOnError(err) { + const websocket = this[kWebSocket]; + + if (websocket._socket[kWebSocket] !== undefined) { + websocket._socket.removeListener('data', socketOnData); + + // + // On Node.js < 14.0.0 the `'error'` event is emitted synchronously. See + // https://github.com/websockets/ws/issues/1940. + // + process.nextTick(resume, websocket._socket); + + websocket.close(err[kStatusCode]); + } + + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit('error', err); + } +} + +/** + * The listener of the `Receiver` `'finish'` event. + * + * @private + */ +function receiverOnFinish() { + this[kWebSocket].emitClose(); +} + +/** + * The listener of the `Receiver` `'message'` event. + * + * @param {Buffer|ArrayBuffer|Buffer[])} data The message + * @param {Boolean} isBinary Specifies whether the message is binary or not + * @private + */ +function receiverOnMessage(data, isBinary) { + this[kWebSocket].emit('message', data, isBinary); +} + +/** + * The listener of the `Receiver` `'ping'` event. + * + * @param {Buffer} data The data included in the ping frame + * @private + */ +function receiverOnPing(data) { + const websocket = this[kWebSocket]; + + if (websocket._autoPong) websocket.pong(data, !this._isServer, NOOP); + websocket.emit('ping', data); +} + +/** + * The listener of the `Receiver` `'pong'` event. + * + * @param {Buffer} data The data included in the pong frame + * @private + */ +function receiverOnPong(data) { + this[kWebSocket].emit('pong', data); +} + +/** + * Resume a readable stream + * + * @param {Readable} stream The readable stream + * @private + */ +function resume(stream) { + stream.resume(); +} + +/** + * The `Sender` error event handler. + * + * @param {Error} The error + * @private + */ +function senderOnError(err) { + const websocket = this[kWebSocket]; + + if (websocket.readyState === WebSocket.CLOSED) return; + if (websocket.readyState === WebSocket.OPEN) { + websocket._readyState = WebSocket.CLOSING; + setCloseTimer(websocket); + } + + // + // `socket.end()` is used instead of `socket.destroy()` to allow the other + // peer to finish sending queued data. There is no need to set a timer here + // because `CLOSING` means that it is already set or not needed. + // + this._socket.end(); + + if (!websocket._errorEmitted) { + websocket._errorEmitted = true; + websocket.emit('error', err); + } +} + +/** + * Set a timer to destroy the underlying raw socket of a WebSocket. + * + * @param {WebSocket} websocket The WebSocket instance + * @private + */ +function setCloseTimer(websocket) { + websocket._closeTimer = setTimeout( + websocket._socket.destroy.bind(websocket._socket), + websocket._closeTimeout + ); +} + +/** + * The listener of the socket `'close'` event. + * + * @private + */ +function socketOnClose() { + const websocket = this[kWebSocket]; + + this.removeListener('close', socketOnClose); + this.removeListener('data', socketOnData); + this.removeListener('end', socketOnEnd); + + websocket._readyState = WebSocket.CLOSING; + + // + // The close frame might not have been received or the `'end'` event emitted, + // for example, if the socket was destroyed due to an error. Ensure that the + // `receiver` stream is closed after writing any remaining buffered data to + // it. If the readable side of the socket is in flowing mode then there is no + // buffered data as everything has been already written. If instead, the + // socket is paused, any possible buffered data will be read as a single + // chunk. + // + if ( + !this._readableState.endEmitted && + !websocket._closeFrameReceived && + !websocket._receiver._writableState.errorEmitted && + this._readableState.length !== 0 + ) { + const chunk = this.read(this._readableState.length); + + websocket._receiver.write(chunk); + } + + websocket._receiver.end(); + + this[kWebSocket] = undefined; + + clearTimeout(websocket._closeTimer); + + if ( + websocket._receiver._writableState.finished || + websocket._receiver._writableState.errorEmitted + ) { + websocket.emitClose(); + } else { + websocket._receiver.on('error', receiverOnFinish); + websocket._receiver.on('finish', receiverOnFinish); + } +} + +/** + * The listener of the socket `'data'` event. + * + * @param {Buffer} chunk A chunk of data + * @private + */ +function socketOnData(chunk) { + if (!this[kWebSocket]._receiver.write(chunk)) { + this.pause(); + } +} + +/** + * The listener of the socket `'end'` event. + * + * @private + */ +function socketOnEnd() { + const websocket = this[kWebSocket]; + + websocket._readyState = WebSocket.CLOSING; + websocket._receiver.end(); + this.end(); +} + +/** + * The listener of the socket `'error'` event. + * + * @private + */ +function socketOnError() { + const websocket = this[kWebSocket]; + + this.removeListener('error', socketOnError); + this.on('error', NOOP); + + if (websocket) { + websocket._readyState = WebSocket.CLOSING; + this.destroy(); + } +} diff --git a/server/node_modules/ws/package.json b/server/node_modules/ws/package.json new file mode 100644 index 0000000..3618050 --- /dev/null +++ b/server/node_modules/ws/package.json @@ -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 (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" + } +} diff --git a/server/node_modules/ws/wrapper.mjs b/server/node_modules/ws/wrapper.mjs new file mode 100644 index 0000000..a8ffabb --- /dev/null +++ b/server/node_modules/ws/wrapper.mjs @@ -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; diff --git a/server/package-lock.json b/server/package-lock.json new file mode 100644 index 0000000..f6fff12 --- /dev/null +++ b/server/package-lock.json @@ -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 + } + } + } + } +} diff --git a/server/package.json b/server/package.json new file mode 100644 index 0000000..11e2293 --- /dev/null +++ b/server/package.json @@ -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" + } +}