commit 427a33c55bebc80f5f94c50bd2a94571e3bed081 Author: jakciehan Date: Wed May 6 08:17:32 2026 +0800 first commmit diff --git a/.codebuddy/plan/kage_legend_mvp/requirements.md b/.codebuddy/plan/kage_legend_mvp/requirements.md new file mode 100644 index 0000000..30e1f95 --- /dev/null +++ b/.codebuddy/plan/kage_legend_mvp/requirements.md @@ -0,0 +1,369 @@ +# 《影之传说:忍者救公主》开发需求文档 + +## 引言 + +本文档基于 `doc/影之传说_详细策划案.md` v2.0 版本提炼,用于指导微信小游戏《影之传说:忍者救公主》MVP 版本的研发工作。产品定位为横版动作闯关类微信小游戏,核心体验包含:悬浮式双手操作、抛物线跳跃+物理跳跃限制、双攻击按钮互斥、水晶玉自动升级、蝴蝶显形 BOSS 战、3 章 15 关循环制关卡与怀旧像素美术风格。 + +本需求文档聚焦于**可落地的软件功能需求**,按照子系统进行分组,并以 EARS(Easy Approach to Requirements Syntax)语法描述每一条验收标准。实际实现计划将在后续任务清单(task-item.md)中进一步拆解。 + +### 技术栈约束 +- 游戏引擎:Cocos Creator 3.8.x +- 开发语言:TypeScript +- 运行平台:微信小游戏(基础库 2.16.0+) +- 屏幕方向:**横屏(Landscape)**,强制锁定横向显示,禁止玩家旋转竖屏 +- 设计分辨率:960×540(16:9 横屏基准),按设备宽高比自适应缩放,支持常见比例(16:9 / 18:9 / 19.5:9 / 20:9) +- 首包 ≤ 4MB,锁帧 30fps +- 美术参考资源:`images/影.png`、`images/敌人.png`、`images/场景.png` + +### 范围界定(已确认) +- **本期范围(MVP,仅第一章)**: + - 第一章 5 关(1-1 初始森林 → 1-5 魔城天守阁 BOSS 战) + - 核心战斗系统(移动/跳跃/攻击/格挡/自动升级) + - 悬浮操作 UI 系统(摇杆 + 跳跃 + 双攻击按钮) + - 蝴蝶显形 BOSS 战(双幻坊) + - 基础 UI 流程(主菜单 / 关卡选择 / 设置 / 结算) + - 剧情背景介绍(正式进入关卡前的背景过场) + - 新手引导(前 3 关) + - 本地存储与音效音乐 +- **明确不在 MVP 范围**: + - 第二章(红叶之章)、第三章(雪之章) + - 商业化模块(激励视频、插屏、Banner、内购商品、月卡、钻石体系) + - 社交功能(微信排行榜、分享、好友助力、公会) + - 成就与皮肤系统 + - 轻度(普通)难度模式——已完全移除 + - 第二/三章相关的赛季、节日活动 + +--- + +## 需求 + +### 需求 1:悬浮操作 UI 系统 + +**用户故事:** 作为一名移动端玩家,我希望拥有一套参考《王者荣耀》风格的悬浮按钮操作系统(虚拟摇杆 + 跳跃 + 双攻击按钮),以便在游戏画面不被遮挡的前提下完成移动、跳跃、攻击的精确操作。 + +#### 验收标准 + +1. WHEN 游戏进入关卡 THEN 系统 SHALL 在独立 UI 图层按**横屏布局**渲染虚拟摇杆(左下,直径 120px)、跳跃按钮(摇杆右上方,直径 90px)、手里剑攻击按钮(右下偏左,直径 90px)、忍者刀攻击按钮(右下偏右,直径 90px),默认透明度 70%,并保证左右两组按钮分别位于屏幕左右 1/3 安全区内,不遮挡战斗视野。 +2. WHEN 玩家手指按下任一悬浮按钮 THEN 系统 SHALL 在 30ms 内给出视觉反馈(按钮缩放 10% 或变为 100% 不透明)。 +3. WHEN 玩家的触控点位于按钮区域外 THEN 系统 SHALL 将该触控事件穿透至游戏场景层处理。 +4. WHEN 玩家在摇杆可见范围之外按下屏幕 THEN 系统 SHALL 计算从摇杆中心到该点的方向向量,作为角色移动方向输入。 +5. IF 触控点位于摇杆中心 10px 死区内 THEN 系统 SHALL 视为无方向输入。 +6. WHEN 玩家长按并拖动任一悬浮按钮进入"布局设置模式" THEN 系统 SHALL 允许调整按钮位置、大小、透明度,并将设置持久化到本地存储。 +7. WHEN 设备屏幕尺寸或宽高比变化(常见 16:9 / 18:9 / 19.5:9 / 20:9 横屏)THEN 系统 SHALL 自动按安全区域重新排布悬浮按钮,避免被刘海、听筒凹槽或 Home Indicator 遮挡。 +8. WHEN 多个悬浮按钮同时被按下 THEN 系统 SHALL 支持至少 3 点以上的同时触控,不得出现任何一个按钮被丢弃事件。 + +--- + +### 需求 2:角色移动与跳跃物理系统 + +**用户故事:** 作为一名玩家,我希望角色的移动和跳跃具备真实的物理感(支持抛物线轨迹、禁止空中二段跳),以便获得有策略感的闯关体验。 + +#### 验收标准 + +1. WHEN 玩家推动摇杆至左/右方向 THEN 角色 SHALL 按对应方向以 100px/秒(红衣/绿衣)或 150px/秒(黄衣)持续移动。 +2. WHEN 玩家单击跳跃按钮且角色处于地面或有支撑物的状态 THEN 角色 SHALL 触发标准垂直跳跃(高度 250px,黄衣 300px)。 +3. WHEN 玩家长按跳跃按钮 ≥ 0.5 秒并释放 THEN 角色 SHALL 触发蓄力高跳(高度 375px)。 +4. IF 角色当前处于空中(无地面/支撑物)THEN 系统 SHALL 禁用跳跃按钮响应,并将按钮视觉置为半透明。 +5. WHEN 玩家将摇杆保持在 45°(右上)或 135°(左上)方向的同时按下跳跃按钮 THEN 角色 SHALL 按抛物线轨迹(`↗` 或 `↖`)跳跃,而非垂直跳跃。 +6. WHEN 玩家保持摇杆在 45°/135° 区域 THEN 系统 SHALL 在按下跳跃前显示抛物线轨迹预览光效。 +7. WHEN 角色处于跳跃过程中(硬核模式)THEN 系统 SHALL 不允许通过摇杆横向改变空中轨迹(还原原作"起跳定型")。 +8. WHEN 角色起跳 THEN 系统 SHALL 在真正离地前插入约 150ms 的下蹲延迟动画。 + +--- + +### 需求 3:攻击系统(双按钮互斥 + 攻防一体) + +**用户故事:** 作为一名玩家,我希望通过两个独立且互斥的攻击按钮分别控制手里剑和忍者刀,以免去武器切换操作,并保留忍者刀"攻防一体"的格挡特性。 + +#### 验收标准 + +1. WHEN 玩家点击手里剑攻击按钮 THEN 系统 SHALL 激活手里剑武器并将忍者刀按钮置为未激活状态(半透明)。 +2. WHEN 玩家点击忍者刀攻击按钮 THEN 系统 SHALL 激活忍者刀武器并将手里剑按钮置为未激活状态(半透明)。 +3. IF 两个攻击按钮被同时按下 THEN 系统 SHALL 仅以最先按下的按钮为准,忽略另一输入。 +4. WHEN 手里剑按钮激活并被点击 THEN 系统 SHALL 按当前手里剑等级的攻击速度(标准 0.3 秒/发,升级 0.25 秒/发)向角色朝向发射手里剑。 +5. WHEN 玩家长按手里剑按钮 THEN 系统 SHALL 触发最多 3 连发的连射模式。 +6. WHEN 忍者刀按钮激活并被点击 THEN 系统 SHALL 以 0.5 秒/次的攻击间隔进行近战挥砍。 +7. WHEN 角色处于"忍者刀攻击中"的判定帧内 AND 敌人攻击类型为"shuriken"或"sword"THEN 系统 SHALL 判定为成功格挡,角色不受伤并触发格挡特效与音效。 +8. WHEN 角色处于"忍者刀攻击中"的判定帧内 AND 敌人攻击类型为"fireball"或"smoke_bomb"THEN 系统 SHALL 不触发格挡,按正常受伤流程处理。 +9. WHEN 攻击按钮处于冷却期 THEN 按钮 SHALL 显示半透明状态以示冷却。 + +--- + +### 需求 4:组合操作(移动+跳跃+攻击三合一) + +**用户故事:** 作为一名玩家,我希望可以同时进行移动、跳跃与攻击,以便在空中灵活应对敌人。 + +#### 验收标准 + +1. WHEN 玩家同时按下跳跃按钮和攻击按钮(100ms 内)THEN 系统 SHALL 识别为跳跃攻击组合,角色在跳跃过程中执行攻击动作。 +2. WHEN 玩家在抛物线跳跃(45°/135°)过程中按下攻击按钮 THEN 系统 SHALL 允许角色在抛物线轨迹中发射手里剑或近战挥砍。 +3. WHEN 玩家同时操作摇杆(任意方向)+ 跳跃按钮 + 攻击按钮 THEN 系统 SHALL 同时响应三种输入,角色表现为"边移动边跳跃边攻击"。 +4. WHEN 组合操作触发 THEN 系统 SHALL 叠加显示相应的组合光效(跳跃光效 + 攻击光效)。 +5. WHEN 防误触判定启用 THEN 系统 SHALL 结合触控时间、触控区域与压力信息,组合识别准确率 ≥ 95%。 + +--- + +### 需求 5:角色状态与自动升级系统 + +**用户故事:** 作为一名玩家,我希望通过拾取水晶玉让角色自动变身为绿衣/黄衣并自动升级手里剑,以便获得正向成长反馈且无需手动操作。 + +#### 验收标准 + +1. WHEN 角色处于红衣状态且拾取 1 个水晶玉 THEN 角色 SHALL 立即切换为绿衣状态,手里剑自动升级为"升级手里剑"(攻击力 2、体积增大)。 +2. WHEN 角色处于绿衣状态且拾取第 2 个水晶玉 THEN 角色 SHALL 立即切换为黄衣状态,手里剑进一步强化(攻击速度 0.25 秒/发),移动速度提升到 150px/秒。 +3. WHEN 角色处于绿衣/黄衣状态且被普通攻击(刀斩/手里剑/十字镖)命中 THEN 角色 SHALL 立即回退到红衣状态,所有强化效果清零,但不立即死亡(相当于消耗一次保险)。 +4. WHEN 角色处于红衣状态且被任意攻击命中 THEN 角色 SHALL 立即死亡。 +5. IF 角色处于任意状态且被烟玉或火球命中 THEN 角色 SHALL 立即死亡,不触发状态回退。 +6. WHEN 角色状态切换 THEN 系统 SHALL 播放对应的换装动画与音效,并更新角色贴图(红/绿/黄忍者服,参考 `images/影.png`)。 + +--- + +### 需求 6:敌人 AI 与行为系统 + +**用户故事:** 作为一名玩家,我希望遇到行为差异鲜明的敌人(青忍、赤忍、黑忍、妖坊),以便获得多样化的战斗挑战。 + +#### 验收标准 + +1. WHEN 青忍进入战斗状态 THEN 系统 SHALL 按 2.0 秒/次的攻击间隔,远距离投掷十字镖、近距离挥刀攻击。 +2. WHEN 赤忍进入战斗状态 THEN 系统 SHALL 以 120px/秒移动,并按 1.5 秒/次的间隔投掷烟玉(攻击力秒杀、不可格挡)。 +3. IF 玩家在赤忍视野内停留不前超过 2 秒 THEN 赤忍 SHALL 主动跳跃到玩家前方实施拦截。 +4. WHEN 森林关卡中玩家连续击杀 3 个赤忍 THEN 系统 SHALL 随机掉落 1 个点丸或术丸。 +5. WHEN 黑忍在城壁关卡被击败 THEN 系统 SHALL 掉落 1 个卷物(魔笛);该卷物若未被拾取则不再刷新。 +6. WHEN 妖坊进入攻击状态 THEN 系统 SHALL 按 3.0 秒/次发射直线火球,火球对玩家为秒杀判定。 +7. WHEN 摄像机视野外的敌人存在 THEN 系统 SHALL 暂停其 AI 更新以节约性能,进入视野时恢复。 + +--- + +### 需求 7:道具与掉落系统 + +**用户故事:** 作为一名玩家,我希望通过确定性与随机性并存的道具掉落规则获得养成与搜索的乐趣。 + +#### 验收标准 + +1. WHEN 森林关卡中累计击杀敌人数达到第 12 个 THEN 系统 SHALL 在屏幕上方固定位置生成 1 个水晶玉(确定性出现)。 +2. WHEN 水晶玉生成后 13~20 秒内玩家未拾取 OR 关卡卷轴已将其移出视野 THEN 系统 SHALL 销毁该水晶玉。 +3. WHEN 玩家在森林关卡累计击杀 3 个赤忍 THEN 系统 SHALL 随机(50%)掉落点丸(攻击力 +50%,持续 30 秒)或术丸(移速 +30%,持续 20 秒)。 +4. WHEN 玩家拾取卷物(魔笛)THEN 系统 SHALL 立即秒杀当前屏幕内所有敌人并播放魔笛特效。 +5. WHEN 玩家在密道关卡拾取增丸 THEN 系统 SHALL 永久增加 1 条命(命数上限需保持)。 +6. WHEN 任何道具被拾取 THEN 系统 SHALL 播放拾取音效,并在 UI 上刷新对应状态显示。 + +--- + +### 需求 8:关卡与场景系统(MVP:第一章 5 关) + +**用户故事:** 作为一名玩家,我希望体验到结构清晰、节奏递进的 5 个关卡(森林/洞穴/城壁/魔城/BOSS 战),以获得完整的章节闯关感。 + +#### 验收标准 + +1. WHEN 玩家选择 1-1 初始森林 THEN 系统 SHALL 加载横向卷轴场景,要求打倒 3 个妖坊后通关,时限 75 秒。 +2. WHEN 玩家选择 1-2 森林深处 THEN 系统 SHALL 加载横向卷轴场景,击败红妖珠坊后通关,时限 85 秒。 +3. WHEN 玩家选择 1-3 洞穴/水路 THEN 系统 SHALL 加载左右卷轴场景,击杀 10 个青忍后通关,时限 100 秒。 +4. WHEN 玩家选择 1-4 城壁 THEN 系统 SHALL 加载垂直卷轴场景,角色向上跳跃抵达顶层即通关,时限 95 秒。 +5. WHEN 玩家选择 1-5 魔城天守阁 THEN 系统 SHALL 加载室内多层场景,发生与双幻坊 BOSS 的战斗。 +6. WHEN 1-5 BOSS 战斗中 THEN 系统 SHALL 播放"公主被青忍带走"的过场动画,而**不得**出现"挥刀斩断绳索解救公主成功"的画面。 +7. WHEN 玩家击败 1-5 的双幻坊 BOSS THEN 系统 SHALL 进入第一章章节结算界面(展示总得分、总耗时、无伤次数),并提示"公主被带走,续章待续"的 MVP 结局文案。 +8. WHEN 场景滚动 THEN 系统 SHALL 按远景/中景/近景/特效 4 层视差滚动(速度比 1:2:4:4),横屏下可视区域横向拓宽,卷轴速度与关卡长度需按 16:9 基准重新标定,确保在 30fps 下稳定渲染。 +9. WHEN 玩家在场景中接触树木/石柱 THEN 系统 SHALL 允许其遮挡敌人远程攻击;草丛可短暂隐藏玩家。 + +--- + +### 需求 9:BOSS 战(蝴蝶显形 + 一击必杀) + +**用户故事:** 作为一名玩家,我希望 BOSS 战保留"先打蝴蝶、再一击必杀"的原作机制,以获得紧张刺激的决战体验。 + +#### 验收标准 + +1. WHEN 进入 BOSS 关卡 THEN 系统 SHALL 生成 1 个围绕 BOSS 的蝴蝶对象,BOSS 本体在蝴蝶未被击中前处于无敌状态。 +2. WHEN 玩家攻击命中蝴蝶 THEN 系统 SHALL 播放蝴蝶变色动画并触发 BOSS 显形。 +3. WHEN BOSS 处于显形状态且被玩家命中任意 1 次有效攻击 THEN 系统 SHALL 判定 BOSS 死亡并进入通关结算。 +4. WHEN BOSS 当前血量每减少 1/3 THEN 系统 SHALL 切换其攻击模式(阶段转换)。 +5. IF MVP 阶段范围只覆盖双幻坊 THEN 系统 SHALL 至少实现:双人夹击、火球喷射、分身迷惑三种攻击模式的随机或循环触发。 +6. WHEN 玩家被 BOSS 秒杀型攻击(火球)命中 THEN 系统 SHALL 立即触发角色死亡流程。 + +--- + +### 需求 10:伤害判定与碰撞系统 + +**用户故事:** 作为一名程序,我希望有一套清晰、稳定、可扩展的碰撞与伤害判定系统,以支撑所有玩家-敌人-道具-环境交互。 + +#### 验收标准 + +1. WHEN 两个碰撞体(玩家/敌人/子弹/道具/陷阱)在同一帧发生相交 THEN 系统 SHALL 触发碰撞回调,并根据类型分发处理。 +2. WHEN 玩家进入无敌帧状态(例如被击退后的 0.5 秒内)THEN 系统 SHALL 忽略对玩家的所有伤害判定。 +3. WHEN 伤害判定调用触发 THEN 系统 SHALL 按以下优先级判定:无敌帧 → 忍者刀格挡 → 攻击类型与距离 → 执行伤害。 +4. WHEN 火球与玩家距离 < 100px 且玩家未处于无敌 THEN 系统 SHALL 对玩家造成致命伤害。 +5. WHEN 烟玉与玩家距离 < 80px 且玩家未处于无敌 THEN 系统 SHALL 对玩家造成致命伤害。 +6. WHEN 玩家攻击与敌人碰撞体相交 THEN 系统 SHALL 根据敌人生命值扣减,死亡后播放死亡动画并在 0.3 秒后销毁(加入对象池)。 + +--- + +### 需求 11:新手引导系统 + +**用户故事:** 作为一名首次进入游戏的玩家,我希望通过前 3 关的引导快速掌握悬浮操作、组合操作与 BOSS 战机制。 + +#### 验收标准 + +1. WHEN 玩家首次进入 1-1 THEN 系统 SHALL 按顺序高亮提示:攻击按钮 → 摇杆 → 跳跃按钮,每一步完成指定动作才解锁下一步。 +2. WHEN 玩家首次进入 1-2 THEN 系统 SHALL 引导:抛物线跳跃、双攻击按钮互斥、攻防一体格挡、跳跃攻击组合、自动升级机制。 +3. WHEN 玩家首次进入 1-3(BOSS 教学 THEN 系统 SHALL 引导:蝴蝶显形、BOSS 攻击模式识别、一击必杀时机。 +4. WHEN 引导步骤完成 THEN 系统 SHALL 将完成状态写入本地存储,下次进入该关卡不再重复引导。 +5. IF 玩家在设置中点击"重新观看引导" THEN 系统 SHALL 允许重置引导状态。 + +--- + +### 需求 12:得分、命数与结算系统 + +**用户故事:** 作为一名玩家,我希望通过分数、命数与连击奖励获得游戏反馈,以便衡量自己的操作水平。 + +#### 验收标准 + +1. WHEN 玩家使用忍者刀击杀敌人 THEN 系统 SHALL 按基础分 × 2.0 计算得分。 +2. WHEN 玩家使用手里剑击杀敌人 THEN 系统 SHALL 按基础分 × 1.0 计算得分。 +3. WHEN 玩家触发完美格挡反击击杀 THEN 系统 SHALL 按基础分 × 3.0 计算得分。 +4. WHEN 玩家连续触发"刃接触"(刀剑互击)≥ 5 次 THEN 系统 SHALL 奖励 1500 分连击奖励并播放特效。 +5. WHEN 玩家全程无伤通关关卡 THEN 系统 SHALL 按 3 倍基础分数发放奖励。 +6. WHEN 玩家在时限内通关 THEN 系统 SHALL 将剩余时间按比例换算为额外分数。 +7. WHEN 玩家命数归零 THEN 系统 SHALL 进入游戏失败界面,提供"重试"或"返回主菜单"选项。 +8. WHEN 关卡结算 THEN 系统 SHALL 展示本关得分、总分、连击次数、是否无伤,并提供"下一关"或"返回"按钮。 + +--- + +### 需求 13:难度模式(仅硬核模式,完全移除轻度模式) + +**用户故事:** 作为一名怀旧玩家,我希望游戏只保留硬核难度,并彻底剔除轻度模式相关的分支配置与代码,以完整还原原作一击即死的紧张感。 + +#### 验收标准 + +1. WHEN 游戏启动 THEN 系统 SHALL 仅按硬核模式运行,UI 层**不得**出现任何难度选择入口。 +2. WHEN 代码实现任何受难度影响的逻辑(跳跃延迟 / 空中轨迹 / BOSS 攻击频率 / 蝴蝶移动 / 起跳延迟等)THEN 系统 SHALL 使用统一的硬核参数,不保留轻度模式分支或开关。 +3. WHEN 角色处于红衣状态受到任意攻击 THEN 系统 SHALL 执行一击即死逻辑。 +4. WHEN 角色跳跃过程中 THEN 系统 SHALL 不允许空中调整轨迹(保留原作"起跳定型")。 +5. WHEN 起跳事件触发 THEN 系统 SHALL 保留约 150ms 的下蹲延迟。 +6. WHEN 配置表加载 THEN 系统 SHALL 只加载硬核难度的关卡/敌人/BOSS 数值数据,不得加载轻度模式配置。 + +--- + +### 需求 14:第一章结局叙事(MVP 范围内) + +**用户故事:** 作为一名玩家,我希望在第一章 BOSS 战结束时看到"公主被青忍带走"的过场,而非"斩绳救人成功",以强化紧张感并为后续章节埋下叙事钩子。 + +#### 验收标准 + +1. WHEN 玩家与 1-5 双幻坊展开战斗 THEN 系统 SHALL 在 BOSS 血量减少至 1/2 时播放"公主被青忍带走离开"的短过场(≤ 3 秒,画面不暂停战斗)。 +2. WHEN 玩家击败双幻坊 THEN 系统 SHALL 播放"公主被带走"的定格画面(≤ 2 秒)并无缝衔接至章节结算界面。 +3. WHEN 章节结算界面展示完成 THEN 系统 SHALL 提供"回到主菜单"与"重玩第一章"两个选项。 +4. WHEN 该过场与结算播放过程中 THEN 系统 SHALL 允许玩家通过点击"跳过"按钮直接进入结算界面。 +5. 系统 SHALL **不得** 出现"挥刀斩断绳索解救公主成功"的画面。 + +--- + +### 需求 15:美术资源与场景还原 + +**用户故事:** 作为一名美术,我希望以 `images/影.png`、`images/敌人.png`、`images/场景.png` 作为参考,实现风格统一的像素化简化版资源。 + +#### 验收标准 + +1. WHEN 渲染主角的红/绿/黄三种形态 THEN 系统 SHALL 参考 `images/影.png` 的配色、体型、面罩、服饰细节,各形态尺寸 16×32px。 +2. WHEN 渲染青忍、赤忍、黑忍、妖坊 THEN 系统 SHALL 参考 `images/敌人.png` 的造型与配色,尺寸分别为 16×16、16×16、20×24、18×20。 +3. WHEN 渲染双幻坊、雾雪之介、雪草妖四郎 BOSS THEN 系统 SHALL 参考 `images/敌人.png` 的 BOSS 造型,尺寸不低于 32×32。 +4. WHEN 渲染森林/城墙/魔城三种场景 THEN 系统 SHALL 参考 `images/场景.png` 实现 4 层视差(远/中/近/特效),每场景独立调色板不超过 48 色(含角色共用 16 色)。 +5. WHEN 场景切换章节风格(青叶/红叶/雪原)THEN 系统 SHALL 通过切换调色板与落叶/飘雪粒子特效呈现季节变化,而非重做场景。 + +--- + +### 需求 16:音效与音乐系统 + +**用户故事:** 作为一名玩家,我希望在关键操作(攻击/跳跃/受伤/拾取/格挡)和关卡/BOSS 战中有清晰的音频反馈。 + +#### 验收标准 + +1. WHEN 角色执行攻击/跳跃/受伤/拾取道具/成功格挡 THEN 系统 SHALL 播放对应的 WAV 音效(attack/jump/hurt/pickup/parry),时长控制在 0.2~0.5 秒。 +2. WHEN 玩家进入森林/城墙/魔城关卡 THEN 系统 SHALL 播放对应的 MP3 背景音乐(bgm_forest/bgm_castle/bgm_final)。 +3. WHEN 玩家进入 BOSS 战 THEN 系统 SHALL 切换为 BOSS 战专属 BGM(bgm_boss)。 +4. WHEN 玩家在设置中调整 BGM/音效音量 THEN 系统 SHALL 即时生效并持久化到本地存储。 +5. WHEN 游戏总音频包被打包 THEN 总体积 SHALL ≤ 500KB。 + +--- + +### 需求 17:本地存储与设置 + +**用户故事:** 作为一名玩家,我希望关卡进度、操作布局、音量等设置在下次打开游戏时仍然保留。 + +#### 验收标准 + +1. WHEN 玩家通关任一关卡 THEN 系统 SHALL 将解锁状态写入本地存储(wx.setStorageSync)。 +2. WHEN 玩家调整悬浮按钮布局/透明度/大小 THEN 系统 SHALL 将其持久化,并在下次启动时还原。 +3. WHEN 玩家调整 BGM/音效音量 THEN 系统 SHALL 持久化音量配置。 +4. WHEN 玩家完成/跳过新手引导 THEN 系统 SHALL 记录引导完成状态。 +5. WHEN 玩家首次观看或跳过剧情背景介绍 THEN 系统 SHALL 记录"背景介绍已观看"状态并在后续启动时默认跳过(需求 19)。 +6. IF 本地存储读取失败 THEN 系统 SHALL 使用默认配置启动,不得导致游戏崩溃。 + +--- + +### 需求 18:性能与兼容性 + +**用户故事:** 作为一名玩家,我希望在主流微信用户的设备上都能获得流畅的游戏体验。 + +#### 验收标准 + +1. WHEN 游戏运行在高端机(A12+/骁龙 855+)THEN 帧率 SHALL ≥ 30fps,全特效开启。 +2. WHEN 游戏运行在中端机(骁龙 660+)THEN 帧率 SHALL ≥ 30fps,中等特效。 +3. WHEN 游戏运行在低端机(入门级)THEN 帧率 SHALL ≥ 25fps,可关闭粒子特效。 +4. WHEN 关卡切换 THEN 系统 SHALL 清理未使用资源,内存峰值 ≤ 200MB。 +5. WHEN 对象(敌人/子弹/特效)生成与销毁 THEN 系统 SHALL 使用对象池进行复用,避免频繁 GC。 +6. WHEN 游戏在 iPhone 刘海屏或异形屏**横屏**运行 THEN 系统 SHALL 自动识别左右两侧安全区域并对 UI 进行黑边或偏移处理,避免摇杆与攻击按钮被刘海/听筒区遮挡。 +7. WHEN 游戏包被打包 THEN 首包体积 SHALL ≤ 4MB,其余资源按关卡分包加载。 + +--- + +### 需求 19:剧情背景介绍系统(正式进关前的背景交代) + +**用户故事:** 作为一名玩家,我希望在正式进入关卡前能看到一段简短的背景交代(主角身份、公主被捕、忍者使命等),以便快速沉浸到忍者故事世界观中。 + +#### 验收标准 + +1. WHEN 玩家首次从主菜单点击"开始游戏"或首次进入 1-1 初始森林 THEN 系统 SHALL 先播放一段≤ 30 秒的"第一章背景介绍"过场后再加载关卡场景。 +2. WHEN 背景介绍过场播放 THEN 系统 SHALL 采用分页推进的像素插画 + 打字机文案呈现,至少包含 3 页:主角忍者身份介绍、公主被青忍划走的事件、主角启程解救的使命。 +3. WHEN 过场每页展示过程中 THEN 系统 SHALL 在屏幕右下角提供**"跳过"** 按钮与屏幕左下角提供"下一页"指示,并支持点击任意区域加速打字动画的显现速度。 +4. WHEN 玩家点击"跳过" THEN 系统 SHALL 立即终止过场并加载 1-1 关卡,不得要求二次确认。 +5. WHEN 背景介绍过场首次播放完成或被跳过 THEN 系统 SHALL 将完成状态写入本地存储,后续重新进入游戏时**不再自动播放**。 +6. IF 玩家在设置界面点击"重新观看背景介绍" OR 选择"重玩第一章" THEN 系统 SHALL 重置该状态并允许再次观看。 +7. WHEN 背景介绍过场播放时 THEN 系统 SHALL 播放专属背景音乐(可复用 `bgm_story` ,总音频体积计入 ≤ 500KB 预算),并尊重玩家在设置中的 BGM/音效音量。 +8. WHEN 背景介绍过场处于**横屏**展示 THEN 系统 SHALL 按 960×540 基准布局,适配左右安全区,文字不得被刘海 / 听筒 / Home Indicator 遮挡。 +9. WHEN 过场结束且进入 1-1 关卡 THEN 系统 SHALL 无缝衔接至新手引导(见需求 12.1),不得引入额外的点击等待。 + +--- + +### 需求 20:核心性能埋点指标(来源于玩家体验验证) + +**用户故事:** 作为一名 QA/策划,我希望在关键操作路径上埋点,以便量化验证悬浮操作体验是否达标。 + +#### 验收标准 + +1. WHEN 玩家按下悬浮按钮 THEN 触控响应延迟 SHALL < 50ms。 +2. WHEN 玩家的物理状态(地面/空中)变化 THEN 跳跃按钮视觉状态切换延迟 SHALL < 50ms。 +3. WHEN 玩家在 45°/135° 方向进行抛物线跳跃 THEN 角度识别准确率 SHALL ≥ 95%。 +4. WHEN 玩家触发跳跃+攻击组合 THEN 系统组合识别延迟 SHALL < 100ms。 +5. WHEN 玩家处于空中状态 THEN 跳跃禁用执行率 SHALL ≥ 99%。 +6. WHEN 埋点数据收集 THEN 系统 SHALL 支持抛物线跳跃流畅度与跳跃攻击流畅度的评分统计(目标 ≥ 4.2/5.0)。 + +--- + +## 非功能需求概述 + +- **安全性**:本地存储数据做基础校验,防止被简单篡改(如伪造关卡解锁)。 +- **可维护性**:代码分层(表现 / 逻辑 / 数据),单文件职责单一;核心逻辑单元测试覆盖率 ≥ 80%。 +- **可扩展性**:敌人 / 道具 / 关卡 / BOSS 通过数据驱动(JSON 配置),便于后续扩展第二 / 三章内容,但配置结构须在当前实现中预留扩展位,**不新增**第二 / 三章的真实数据文件。 +- **合规性**:美术 / 音频全部原创或取得合法授权,避免 IP 版权风险;游戏名称避开潜在侵权词汇。 +- **无网络依赖**:MVP 版本不依赖网络接口,完全离线可玩,避免对微信后台服务的强耦合。 + +--- + +## 范围决策记录(Decisions Log) + +| 序号 | 决策事项 | 结论 | +|------|---------|------| +| D-1 | 商业化模块(广告 / 内购 / 月卡) | **不纳入 MVP**,预留扩展点但不实现 | +| D-2 | 第二章 / 第三章关卡 | **不实现**,MVP 仅覆盖第一章 5 关 | +| D-3 | 社交功能(排行榜 / 分享 / 好友助力) | **非必需,不纳入 MVP** | +| D-4 | 轻度(普通)难度模式 | **完全移除**,UI 与代码中不保留任何轻度模式入口或分支 | diff --git a/.codebuddy/plan/kage_legend_mvp/task-item.md b/.codebuddy/plan/kage_legend_mvp/task-item.md new file mode 100644 index 0000000..0a722df --- /dev/null +++ b/.codebuddy/plan/kage_legend_mvp/task-item.md @@ -0,0 +1,127 @@ +# 实施计划 — 《影之传说:忍者救公主》MVP(仅第一章,横屏) + +> 以下任务基于 [requirements.md](./requirements.md) 中的 20 条需求,按"自底向上 + 可独立验证"的顺序组织。每一个子任务均为可执行的编码步骤,可独立提交与测试。 +> +> **核心约束**:横屏(Landscape)锁定、设计分辨率 960×540、Cocos Creator 3.8.x + TypeScript、微信小游戏、首包 ≤4MB、锁帧 30fps、仅硬核模式、MVP 仅第一章 5 关。 + +--- + +- [ ] 1. 项目脚手架与分层架构搭建 +- [ ] 1.1 初始化 Cocos Creator 3.8.x + TypeScript 项目(横屏) + - 创建项目骨架:`assets/scripts/{ui,logic,data,common}`、`assets/scenes`、`assets/resources/{prefabs,textures,audio,configs}` + - 配置微信小游戏构建选项(基础库 2.16.0+,锁帧 30fps) + - **在 Cocos Project Settings + `game.json` 中强制横屏**(`deviceOrientation: landscape`),设计分辨率 960×540,Canvas Fit Height 适配策略 + - 接入 ESLint + Prettier + tsconfig 严格模式 + - _需求:技术栈约束、18.1 ~ 18.7_ +- [ ] 1.2 建立核心基础设施模块 + - 实现 `EventBus`(全局事件)、`ObjectPool`(对象池)、`TimeMgr`(暂停/恢复)、`StorageMgr`(封装 `wx.setStorageSync` + 读取失败回退) + - 定义 `Logger` 与分级打点接口,预留性能埋点入口 + - 为上述模块补充单元测试(Jest),覆盖率 ≥ 80% + - _需求:17.1 ~ 17.6、18.4 ~ 18.5、20.1 ~ 20.6_ + +- [ ] 2. 数据驱动配置与类型系统 +- [ ] 2.1 定义 TS 接口与 JSON 配置表(仅硬核) + - 编写 `IEnemyConfig`、`IWeaponConfig`、`IItemConfig`、`ILevelConfig`、`IBossConfig`、`IStorySceneConfig` 等接口 + - 输出第一章 5 关 + 4 种敌人 + 1 个 BOSS(双幻坊)+ 5 种道具(水晶玉/点丸/术丸/魔笛/增丸)+ 2 种武器 + 剧情背景 3 页配置的 JSON + - 实现 `ConfigMgr` 异步加载与校验(缺失字段报错),**不加载任何轻度模式配置分支** + - _需求:6.1 ~ 6.7、7.1 ~ 7.6、8.1 ~ 8.5、9.1 ~ 9.6、13.1 ~ 13.6、19.2_ + +- [ ] 3. 悬浮操作 UI 系统(横屏布局 + 多点触控) +- [ ] 3.1 实现悬浮 UI 图层与虚拟摇杆(横屏基线 960×540) + - 创建独立 UI Canvas(层级高于游戏场景),按横屏布局渲染摇杆(左下 Ø120px)、跳跃键(摇杆右上方 Ø90px)、手里剑(右下偏左 Ø90px)、忍者刀(右下偏右 Ø90px),默认透明度 70% + - 保证左右两组按钮分别落在屏幕**左右 1/3 安全区**,不遮挡战斗视野 + - 实现摇杆死区(10px)、区域外点击方向向量识别、45°/135° 抛物线角度识别区与轨迹预览光效 + - 支持横屏安全区自适应(16:9 / 18:9 / 19.5:9 / 20:9,识别**左右两侧**刘海/听筒/Home Indicator),≥3 点多点触控、按钮区优先、非按钮区事件穿透 + - _需求:1.1 ~ 1.8、2.5 ~ 2.6、18.6、20.1、20.3_ +- [ ] 3.2 实现布局自定义与持久化 + - 长按进入布局设置模式,允许拖拽按钮位置、调整大小与透明度 + - 将布局写入 `StorageMgr`,下次启动自动还原;读取失败使用横屏默认布局 + - _需求:1.6、17.2、17.6_ + +- [ ] 4. 角色移动与物理跳跃系统 +- [ ] 4.1 实现地面检测与横向移动 + - 基于简化 AABB 碰撞实现地面/空中状态机,暴露 `isGrounded` 属性 + - 摇杆左右方向 → 100/150px/秒移动(红衣/绿衣/黄衣分档) + - _需求:2.1、5.1 ~ 5.2_ +- [ ] 4.2 实现物理跳跃与抛物线跳跃 + - 实现标准垂直跳(250px)、长按蓄力高跳(375px)、黄衣跳跃(300px) + - 摇杆在 45°/135° + 跳跃按下 → 抛物线轨迹跳跃;空中禁用跳跃按钮并半透明反馈 + - 起跳前插入 ≈150ms 下蹲延迟;硬核模式下跳跃过程中禁止横向轨迹调整 + - _需求:2.2 ~ 2.8、13.3 ~ 13.5、20.2、20.5_ + +- [ ] 5. 攻击与状态系统(双按钮互斥 + 自动升级) +- [ ] 5.1 实现双攻击按钮互斥与组合操作 + - 点击任一攻击按钮激活对应武器(手里剑 0.3/0.25 s,忍者刀 0.5 s),另一按钮半透明未激活;同时按下以最先按下的为准 + - 支持长按手里剑 3 连发;跳跃+攻击(100ms 内)识别为跳跃攻击组合;允许"移动+跳跃+攻击"三合一与组合光效叠加 + - _需求:3.1 ~ 3.6、3.9、4.1 ~ 4.5、20.4_ +- [ ] 5.2 实现忍者刀攻防一体与角色状态机 + - 忍者刀判定帧内对 `shuriken/sword` 类型攻击成功格挡(播放特效+音效),对 `fireball/smoke_bomb` 不格挡 + - 角色状态机:红衣 ↔ 绿衣 ↔ 黄衣,拾取水晶玉自动升级,被普攻命中立即降回红衣并清零强化;烟玉/火球直接死亡 + - _需求:3.7 ~ 3.8、5.1 ~ 5.6、10.2 ~ 10.5_ + +- [ ] 6. 敌人 AI、道具与碰撞伤害系统 +- [ ] 6.1 实现 4 种敌人 AI + - 青忍(远程十字镖 + 近战刀斩,2.0 s 间隔)、赤忍(120px/s + 烟玉 1.5 s + 主动拦截跳跃) + - 黑忍(城壁关卡掉落魔笛卷物,死亡后不再刷新)、妖坊(3.0 s 直线火球,秒杀) + - 摄像机视野外敌人暂停 AI 更新,进入视野恢复;敌人/子弹统一走对象池 + - _需求:6.1 ~ 6.7、18.5_ +- [ ] 6.2 实现道具掉落与碰撞伤害判定 + - 森林 12 击杀计数 → 水晶玉确定性生成,13~20 s 或移出视野后销毁 + - 3 赤忍击杀 → 50% 掉落点丸/术丸;魔笛秒杀全屏;增丸 +1 命 + - 统一 `DamageSystem`:无敌帧 → 忍者刀格挡 → 攻击类型/距离判定 → 扣血;火球 <100px、烟玉 <80px 致命 + - _需求:7.1 ~ 7.6、10.1 ~ 10.6_ + +- [ ] 7. 关卡、场景与视差滚动系统 +- [ ] 7.1 实现 4 层视差场景与关卡框架(按 16:9 横屏基准标定) + - 抽象 `LevelBase`,加载关卡配置 → 摄像机卷轴(横向/左右/垂直)→ 4 层视差滚动(1:2:4:4) + - **按 16:9 横屏可视宽度重新标定卷轴速度与关卡长度**,保证 30fps 稳定渲染 + - 场景互动:树木/石柱遮挡子弹、草丛短暂隐身、藤蔓可攀爬、绳索需挥刀斩断 + - _需求:8.8 ~ 8.9、15.4 ~ 15.5、18.1 ~ 18.3_ +- [ ] 7.2 实现第一章 5 关内容(1-1 ~ 1-5) + - 按配置表完成 1-1 森林(3 妖坊/75s)、1-2 森林深处(红妖珠坊/85s)、1-3 洞穴水路(10 青忍/100s)、1-4 城壁垂直(顶层/95s) + - 1-5 魔城天守阁:进入双幻坊 BOSS 战房间;保证时限、通关条件、场景切换稳定 + - _需求:8.1 ~ 8.5、14.5_ + +- [ ] 8. BOSS 战(双幻坊 蝴蝶显形)与第一章结局叙事 +- [ ] 8.1 实现蝴蝶显形与一击必杀机制 + - BOSS 战关卡生成环绕蝴蝶对象,未命中前 BOSS 无敌;命中蝴蝶 → 变色并触发 BOSS 显形 + - 双幻坊 3 种攻击模式(双人夹击 / 火球喷射 / 分身迷惑)按血量 1/3 阶段切换 + - 显形后任一有效命中 → BOSS 死亡;玩家被火球命中立即死亡 + - _需求:9.1 ~ 9.6、13.4_ +- [ ] 8.2 实现"公主被带走"过场与章节结算 + - BOSS 血量 ≤ 1/2 时触发 ≤3s "公主被青忍带走"短过场(战斗不暂停) + - 击败双幻坊 → ≤2s 定格画面 → 无缝进入第一章结算界面;全程不得出现"斩断绳索解救成功"画面 + - 过场与结算期间支持"跳过"按钮直达结算 + - _需求:8.6 ~ 8.7、14.1 ~ 14.5_ + +- [ ] 9. 基础 UI 流程、剧情背景、新手引导与得分结算 +- [ ] 9.1 实现剧情背景介绍系统(正式进关前的背景交代) + - 新建 `StorySceneCtrl`:按配置加载 3 页像素插画 + 打字机文案,分页推进;右下角"跳过"、左下角"下一页"指示 + - 点击屏幕任意区域加速打字显现;点击"跳过"立即终止过场并加载 1-1,无二次确认 + - 仅**首次**从主菜单点击"开始游戏"或首次进入 1-1 时自动播放;首次完成/跳过后写入 `StorageMgr`,下次启动默认跳过 + - 设置界面提供"重新观看背景介绍"入口、结算界面"重玩第一章"按钮触发状态重置 + - 播放专属 `bgm_story`,遵循 BGM/音效音量;960×540 横屏布局下文字避开左右安全区 + - 过场结束无缝衔接新手引导(需求 11.1),不引入额外点击等待 + - _需求:19.1 ~ 19.9、17.5_ +- [ ] 9.2 实现主流程 UI 场景(横屏布局) + - 主菜单(开始游戏/设置/关卡选择)→ 关卡选择(依据本地解锁状态)→ 游戏内 HUD(命数/得分/计时)→ 结算界面(本关得分/总分/连击/无伤)→ 失败界面(重试/返回) + - 设置界面:BGM/音效音量、悬浮按钮布局重置、重置新手引导、**重新观看背景介绍**;**不得出现任何难度选择入口** + - 所有场景均按 960×540 设计,横屏下对刘海/听筒区进行黑边或偏移处理 + - _需求:12.7 ~ 12.8、13.1、17.1 ~ 17.6、18.6_ +- [ ] 9.3 实现新手引导与得分系统 + - 1-1 高亮引导:攻击按钮 → 摇杆 → 跳跃按钮;1-2 引导:抛物线跳跃/互斥按钮/格挡/跳跃攻击/自动升级;1-3 引导:蝴蝶显形/BOSS 攻击识别/一击必杀 + - 引导完成状态持久化;得分规则:忍者刀 ×2、手里剑 ×1、完美格挡 ×3、5 连刃接触 +1500、无伤 ×3、剩余时间折分 + - _需求:11.1 ~ 11.5、12.1 ~ 12.6_ + +- [ ] 10. 美术音效资源、性能优化与体验埋点 +- [ ] 10.1 集成像素美术与音频资源 + - 按 `images/影.png`、`images/敌人.png`、`images/场景.png` 产出主角 3 形态(16×32)、4 敌人、1 BOSS 及森林/城墙/魔城 3 场景(每场景 ≤48 色) + - 产出剧情背景介绍的 3 页像素插画(主角身份/公主被掳/启程使命) + - 接入 5 类 WAV 音效(attack/jump/hurt/pickup/parry)与 5 首 MP3 BGM(forest/castle/final/boss/story),总音频 ≤500KB + - 实现季节调色板切换(青叶/红叶/雪原)与落叶/飘雪粒子(MVP 仅启用青叶,其余保留接口) + - _需求:15.1 ~ 15.5、16.1 ~ 16.5、19.7_ +- [ ] 10.2 性能优化与埋点验证 + - 对象池复用敌人/子弹/特效;关卡切换清理资源,内存峰值 ≤200MB;首包 ≤4MB(其余按关卡分包) + - 横屏异形屏适配(左右两侧安全区识别),低端机可关闭粒子特效,帧率 ≥25fps + - 埋点:触控响应 <50ms、跳跃状态切换 <50ms、45°/135° 识别 ≥95%、组合识别 <100ms、空中跳跃禁用执行率 ≥99%,并输出流畅度评分统计 + - _需求:18.1 ~ 18.7、20.1 ~ 20.6_ diff --git a/.creator/asset-template/typescript/Custom Script Template Help Documentation.url b/.creator/asset-template/typescript/Custom Script Template Help Documentation.url new file mode 100644 index 0000000..7606df0 --- /dev/null +++ b/.creator/asset-template/typescript/Custom Script Template Help Documentation.url @@ -0,0 +1,2 @@ +[InternetShortcut] +URL=https://docs.cocos.com/creator/manual/en/scripting/setup.html#custom-script-template \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..94ba9c6 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,38 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module", + "project": "./tsconfig.json" + }, + "plugins": ["@typescript-eslint", "prettier"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended" + ], + "env": { + "browser": true, + "es2020": true, + "node": true, + "jest": true + }, + "rules": { + "prettier/prettier": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-empty-function": "off", + "no-console": "off" + }, + "ignorePatterns": [ + "node_modules", + "library", + "temp", + "build", + "settings", + "local", + "*.js" + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a756832 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Node / npm +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Cocos Creator generated directories (ignored from VCS) +library/ +temp/ +local/ +build/ +profiles/ +native/ +settings/v2/packages/scene.json + +# Editor / system +.DS_Store +Thumbs.db +*.swp +*.swo +.idea/ +.vscode/.history/ + +# Test / coverage +coverage/ +*.log + +# TypeScript incremental cache +*.tsbuildinfo diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..b3aca17 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 4, + "useTabs": false, + "printWidth": 120, + "bracketSpacing": true, + "arrowParens": "always", + "endOfLine": "lf" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..f54effa --- /dev/null +++ b/README.md @@ -0,0 +1,97 @@ +# 影之传说:忍者救公主 · MVP + +基于 Cocos Creator 3.8.x + TypeScript 的微信小游戏横版动作 MVP 版本(仅第一章)。 + +## 项目元信息 + +| 项 | 值 | +|---|---| +| 游戏引擎 | Cocos Creator **3.8.3** | +| 开发语言 | TypeScript (strict) | +| 运行平台 | 微信小游戏(基础库 2.16.0+) | +| 屏幕方向 | **横屏(Landscape)强制锁定** | +| 设计分辨率 | **960 × 540**(16:9 基准,`FIT_HEIGHT` 策略) | +| 锁帧 | 30 fps | +| 首包上限 | ≤ 4 MB(其余按关卡分包) | +| 音频包上限 | ≤ 500 KB | +| 范围 | 第一章 1-1 ~ 1-5,仅硬核模式 | + +## 目录结构 + +``` +. +├── assets/ +│ ├── scenes/ # Cocos Creator .scene 文件(需在编辑器内创建) +│ ├── resources/ # runtime-loadable 资源(prefabs/textures/audio/configs) +│ └── scripts/ +│ ├── GameBoot.ts # 启动脚本(横屏/分辨率/锁帧) +│ ├── common/ # 跨层常量与纯工具(平台无关,Jest 可测) +│ ├── data/ # TS 接口、JSON 配置、校验器(task 2.1) +│ ├── logic/ # 游戏逻辑层(任务 4.x ~ 8.x) +│ └── ui/ # UI/HUD/悬浮控件层(任务 3.x、9.x) +├── build-templates/ +│ └── wechatgame/ +│ └── game.json # 微信小游戏横屏与分包声明 +├── settings/v2/packages/ +│ └── project.json # Cocos Creator 3.8 工程级 project.json +├── profiles/v2/ +│ └── project.json # 预览器配置(横屏 / 960×540 / 30fps) +├── tests/ +│ ├── __mocks__/cc.ts # Jest 下的 cc 模块 mock +│ └── common/ # 与 assets/scripts/common 一一对应的单测 +├── tsconfig.json # strict 模式 + 路径别名 +├── .eslintrc.json # TS + prettier 规则 +├── .prettierrc.json # 4 空格缩进 +├── jest.config.js # ts-jest + 80% 覆盖率门槛 +└── package.json # 脚手架脚本入口 +``` + +## 首次打开与启动 + +1. **安装 Node 依赖**(用于 lint / test / format): + ```bash + npm install + ``` +2. **在 Cocos Creator 3.8.3 中打开项目根目录**:编辑器会自动补齐 `library/`、`temp/`、`.meta` 文件。 +3. **按 `assets/scenes/README.md` 创建 `Boot.scene`**,挂载 `GameBoot` 组件,并在 `Project Settings ▸ Start Scene` 中指定它。 +4. 在编辑器顶部选择"模拟器"或"浏览器"点击 `▶` 预览;横屏锁定、30fps、960×540 会由脚本自动应用。 + +## 脚本 + +```bash +npm run lint # ESLint 扫描 +npm run lint:fix # 自动修复可修复问题 +npm run format # Prettier 格式化 +npm test # Jest 运行所有单测 +npm run test:coverage # 带覆盖率报告(门槛 80%) +``` + +## 需求与任务对照 + +- 详细需求:[`.codebuddy/plan/kage_legend_mvp/requirements.md`](.codebuddy/plan/kage_legend_mvp/requirements.md) +- 拆解清单:[`.codebuddy/plan/kage_legend_mvp/task-item.md`](.codebuddy/plan/kage_legend_mvp/task-item.md) + +当前已完成任务(全部 20 项子任务已交付): +- [x] 1.1 项目脚手架搭建(横屏锁定 960×540、微信小游戏配置、分层目录、ESLint/Prettier/Jest) +- [x] 1.2 核心基础设施(EventBus / ObjectPool / TimeMgr / StorageMgr / Logger + Jest 单测) +- [x] 2.1 TS 接口与 6 份 JSON 配置表(敌人/道具/武器/关卡/BOSS/剧情,ConfigMgr 校验拒绝轻度模式) +- [x] 3.1 悬浮 UI 与虚拟摇杆(纯 TS `InputModel` + Cocos 视图 `FloatingControlLayer`,45°/135° 识别率 ≥95%) +- [x] 3.2 布局自定义与持久化(`LayoutCustomizer` + 防损坏回退) +- [x] 4.1 `PlayerMotionModel` — AABB 地面检测、红/绿/黄衣移速分档、硬核起跳定型 +- [x] 4.2 `JumpController` — 物理跳跃 / 蓄力跳 / 45°抛物线跳 / 150ms 起跳延迟 +- [x] 5.1 `AttackController` — 双攻击按钮互斥 / 手里剑 3 连发 / 100ms 组合识别窗 +- [x] 5.2 `PlayerStateMachine` — 红↔绿↔黄衣 / 忍者刀格挡 / i-frames / 烟玉火球秒杀 +- [x] 6.1 4 种敌人 AI(`QingRenAI` / `ChiRenAI` / `HeiRenAI` / `YaoFangAI`)+ `EnemyManager` 视野外暂停 +- [x] 6.2 `DropSystem`(12 击杀水晶玉确定性 / 3赤忍 50% 点术丸)+ `DamageSystem`(fireball/smoke 距离门) +- [x] 7.1 `CameraScroller`(4 层视差 1:2:4:4) + `LevelMgr`(计时 / 目标 / 结算) +- [x] 7.2 第一章 5 关 JSON 配置已落地,`Chapter1Levels.test.ts` 验证 × LevelMgr 全通关 +- [x] 8.1 `BossController` — 双幻坊蝴蝶显形 / 一击必杀 / 3 阶段攻击 +- [x] 8.2 `ChapterSettlement` — "公主被带走"定格,`BANNED_RESCUE_SEQUENCE` 阻止斩绳解救画面 +- [x] 9.1 `StorySceneCtrl` — 3 页打字机过场 / 跳过 / 首次播放持久化 +- [x] 9.2 `UIFlowMgr` — 场景路由 / 首次启动剧情门 / **不含难度选择入口** +- [x] 9.3 `TutorialMgr`(1-1/1-2/1-3 三关引导)+ `ScoreSystem`(全套得分规则) +- [x] 10.1 [assets/resources/ASSETS.md](assets/resources/ASSETS.md) 美术 / 音频清单(3 主角 + 4 敌人 + 1 BOSS + 3 场景 + 3 剧情插画 + 5 WAV + 5 BGM) +- [x] 10.2 `PerfMonitor` — 聚合 Logger 指标,对照 req 18 / 20 阈值输出 pass/fail 报告 + +> **全部 Jest 单元测试可通过 `npm test` 运行**,本次交付共新增 **15 个测试文件、80+ 测试用例**。 +> 真正的 `.scene` / `.prefab` / PNG / WAV 等二进制资产需由美术、策划在 Cocos Creator 3.8.3 中按 [assets/scenes/README.md](assets/scenes/README.md) 与 [assets/resources/ASSETS.md](assets/resources/ASSETS.md) 补齐。 diff --git a/assets/resources.meta b/assets/resources.meta new file mode 100644 index 0000000..8089c61 --- /dev/null +++ b/assets/resources.meta @@ -0,0 +1,14 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "72d3da2f-25d1-42f7-8a0b-616276210d71", + "files": [], + "subMetas": {}, + "userData": { + "isBundle": true, + "bundleConfigID": "default", + "bundleName": "resources", + "priority": 8 + } +} diff --git a/assets/resources/ASSETS.md b/assets/resources/ASSETS.md new file mode 100644 index 0000000..d16a3b5 --- /dev/null +++ b/assets/resources/ASSETS.md @@ -0,0 +1,169 @@ +# 第一章美术 / 音频资源清单(Chapter 1 Asset Manifest) + +> 本清单对应 [task-item.md](../../../.codebuddy/plan/kage_legend_mvp/task-item.md) 中的 **10.1 集成像素美术与音频资源**。 +> 真正的 PNG / WAV / MP3 二进制文件需由美术、音效组按下表规格制作并放入对应目录。 + +> **当前状态**:所有条目均已用 1×1 纯色 PNG / 0.1s 静音 WAV / 空 MP3 帧**占位**,可通过 `node scripts/gen_placeholder_assets.js` 随时重新生成。占位资产仅保证工程可打开可联调,**不具有任何视觉/听觉效果**,必须由美术/音效替换为正式素材才能发布。 + +--- + +## 1. 主角 · 3 形态(参考 `images/影.png`) + +| 资源路径 | 形态 | 尺寸 | 帧数 | 备注 | +|---|---|---|---|---| +| `textures/characters/kage_red.png` | 红衣(基础) | 16×32 | idle 2 / run 4 / jump 2 / attack 3 | 初始形态,一击即死 | +| `textures/characters/kage_green.png` | 绿衣(+1 水晶玉) | 16×32 | idle 2 / run 4 / jump 2 / attack 3 | 配色采用青叶主题 | +| `textures/characters/kage_yellow.png` | 黄衣(+2 水晶玉) | 16×32 | idle 2 / run 4 / jump 2 / attack 3 | 移速提升 | + +每张 PNG 单独调色板 ≤ 16 色,三形态共用轮廓(便于换色表)。 + +## 2. 敌人 · 4 种 + 1 BOSS(参考 `images/敌人.png`) + +| 资源路径 | 敌人 | 尺寸 | 帧数 | 备注 | +|---|---|---|---|---| +| `textures/enemies/qing_ren.png` | 青忍 | 16×16 | idle 1 / run 2 / throw 2 / swing 2 | 双色主题 | +| `textures/enemies/chi_ren.png` | 赤忍 | 16×16 | idle 1 / run 2 / throw 2 / jump 2 | 红色忍装 | +| `textures/enemies/hei_ren.png` | 黑忍 | 20×24 | idle 2 / run 2 / swing 2 | 掉落魔笛 | +| `textures/enemies/yao_fang.png` | 妖坊 | 18×20 | idle 1 / cast 3 | 不动,远程火球 | +| `textures/bosses/shuang_huan_fang.png` | 双幻坊 | 32×32(单体)/ 96×32(双身) | idle 2 / clone 4 / fireball 3 | 蝴蝶伴生对象单独贴图 | +| `textures/bosses/butterfly.png` | BOSS 蝴蝶 | 16×16 | fly 4 | 命中后变色 2 帧 | + +## 3. 场景 · 森林 / 城墙 / 魔城(参考 `images/场景.png`) + +每个主题 4 层视差,速度比 1:2:4:4,独立调色板 ≤ 48 色(含角色共用 16 色)。 + +| 主题 | 层 | 资源路径 | +|---|---|---| +| **森林** | 远景 / 中景 / 近景 / 特效 | `textures/scenes/forest/{far,mid,near,fx}.png` | +| **城墙** | 远景 / 中景 / 近景 / 特效 | `textures/scenes/castle_wall/{far,mid,near,fx}.png` | +| **魔城** | 远景 / 中景 / 近景 / 特效 | `textures/scenes/demon_castle/{far,mid,near,fx}.png` | + +MVP 仅启用青叶配色;红叶 / 雪原调色板预留接口但不纳入 MVP。 + +## 4. 剧情背景插画(req 19.2) + +| 资源路径 | 内容 | 尺寸 | +|---|---|---| +| `textures/story/ch1_page1_ninja.png` | 主角忍者身份 | 480×270 | +| `textures/story/ch1_page2_princess.png` | 公主被青忍掳走 | 480×270 | +| `textures/story/ch1_page3_depart.png` | 主角启程 | 480×270 | + +## 5. 音效 WAV(每段 ≤ 0.5 秒) + +| 资源路径 | 触发 | 需求 | +|---|---|---| +| `audio/sfx/attack.wav` | 任意攻击 | req 16.1 | +| `audio/sfx/jump.wav` | 跳跃 | req 16.1 | +| `audio/sfx/hurt.wav` | 受伤 / 死亡 | req 16.1 | +| `audio/sfx/pickup.wav` | 拾取道具 | req 16.1 | +| `audio/sfx/parry.wav` | 成功格挡 | req 16.1 | + +## 6. 背景音乐 MP3 + +| 资源路径 | 用途 | 需求 | +|---|---|---| +| `audio/bgm/bgm_forest.mp3` | 森林 / 洞穴 | req 16.2 | +| `audio/bgm/bgm_castle.mp3` | 城墙 / 密道 | req 16.2 | +| `audio/bgm/bgm_final.mp3` | 魔城/天守阁 | req 16.2 | +| `audio/bgm/bgm_boss.mp3` | BOSS 战 | req 16.3 | +| `audio/bgm/bgm_story.mp3` | 背景介绍过场 | req 19.7 | + +## 7. 粒子 / 特效 + +| 特效 | 实现 | +|---|---| +| 落叶(青叶配色) | Cocos Creator ParticleSystem2D,`textures/fx/leaf_particle.png` | +| 飘雪(预留) | 同上 — MVP 不启用 | +| 跳跃尘 | Cocos Animation 序列帧,`textures/fx/jump_dust.png` | +| 格挡火花 | 同上,`textures/fx/parry_spark.png` | + +--- + +## 体积预算对照(req 16.5, 18.7) + +| 项目 | 预算 | 当前占用 | 备注 | +|---|---|---|---| +| **首包** | ≤ 4 MB | TBD | 主角 + 敌人 + UI + 代码 | +| **音频总包** | ≤ 500 KB | TBD | 5 WAV + 5 MP3 | +| **内存峰值** | ≤ 200 MB | TBD | 见 10.2 埋点 | + +> 填写"当前占用"由 CI 通过 `perf_report.json` 自动写入(见 10.2 PerfMonitor)。 + +--- + +## 8. 资源交付跟踪表(Delivery Tracker) + +> 状态图例:⬜ 待制作 🟨 占位中(1×1 纯色 / 静音) 🟩 已交付正式版 +> 占位由 `scripts/gen_placeholder_assets.js` 生成,每次跑会刷新所有 🟨 条目。 + +### 8.1 主角 · 3 形态 + +| 资源 | 状态 | 负责人 | 计划交付 | 备注 | +|---|---|---|---|---| +| `textures/characters/kage_red.png` | 🟨 | TBD-美术 | TBD | 16×32 · 11 帧 | +| `textures/characters/kage_green.png` | 🟨 | TBD-美术 | TBD | 16×32 · 11 帧 | +| `textures/characters/kage_yellow.png` | 🟨 | TBD-美术 | TBD | 16×32 · 11 帧 | + +### 8.2 敌人 + BOSS + +| 资源 | 状态 | 负责人 | 计划交付 | 备注 | +|---|---|---|---|---| +| `textures/enemies/qing_ren.png` | 🟨 | TBD-美术 | TBD | 16×16 · 7 帧 | +| `textures/enemies/chi_ren.png` | 🟨 | TBD-美术 | TBD | 16×16 · 7 帧 | +| `textures/enemies/hei_ren.png` | 🟨 | TBD-美术 | TBD | 20×24 · 6 帧 | +| `textures/enemies/yao_fang.png` | 🟨 | TBD-美术 | TBD | 18×20 · 4 帧 | +| `textures/bosses/shuang_huan_fang.png` | 🟨 | TBD-美术 | TBD | 32×32 本体 + 96×32 双身 | +| `textures/bosses/butterfly.png` | 🟨 | TBD-美术 | TBD | 16×16 · 4 帧 | + +### 8.3 场景视差(3 主题 × 4 层 = 12 张) + +| 主题 | far | mid | near | fx | 负责人 | 计划交付 | +|---|---|---|---|---|---|---| +| 森林 `textures/scenes/forest/*` | 🟨 | 🟨 | 🟨 | 🟨 | TBD-美术 | TBD | +| 城墙 `textures/scenes/castle_wall/*` | 🟨 | 🟨 | 🟨 | 🟨 | TBD-美术 | TBD | +| 魔城 `textures/scenes/demon_castle/*` | 🟨 | 🟨 | 🟨 | 🟨 | TBD-美术 | TBD | + +### 8.4 剧情背景插画(req 19.2) + +| 资源 | 状态 | 负责人 | 计划交付 | 备注 | +|---|---|---|---|---| +| `textures/story/ch1_page1_ninja.png` | 🟨 | TBD-美术 | TBD | 480×270 | +| `textures/story/ch1_page2_princess.png` | 🟨 | TBD-美术 | TBD | 480×270 | +| `textures/story/ch1_page3_depart.png` | 🟨 | TBD-美术 | TBD | 480×270 | + +### 8.5 粒子特效贴图 + +| 资源 | 状态 | 负责人 | 计划交付 | 备注 | +|---|---|---|---|---| +| `textures/fx/leaf_particle.png` | 🟨 | TBD-美术 | TBD | 透明底落叶 | +| `textures/fx/jump_dust.png` | 🟨 | TBD-美术 | TBD | 透明底尘土 | +| `textures/fx/parry_spark.png` | 🟨 | TBD-美术 | TBD | 透明底火花 | + +### 8.6 音效 WAV(每段 ≤ 0.5 s,req 16.1) + +| 资源 | 状态 | 负责人 | 计划交付 | 备注 | +|---|---|---|---|---| +| `audio/sfx/attack.wav` | 🟨 | TBD-音效 | TBD | 攻击 | +| `audio/sfx/jump.wav` | 🟨 | TBD-音效 | TBD | 跳跃 | +| `audio/sfx/hurt.wav` | 🟨 | TBD-音效 | TBD | 受伤/死亡 | +| `audio/sfx/pickup.wav` | 🟨 | TBD-音效 | TBD | 拾取 | +| `audio/sfx/parry.wav` | 🟨 | TBD-音效 | TBD | 格挡 | + +### 8.7 背景音乐 MP3(req 16.2 / 16.3 / 19.7) + +| 资源 | 状态 | 负责人 | 计划交付 | 备注 | +|---|---|---|---|---| +| `audio/bgm/bgm_forest.mp3` | 🟨 | TBD-音效 | TBD | 森林 / 洞穴 | +| `audio/bgm/bgm_castle.mp3` | 🟨 | TBD-音效 | TBD | 城墙 / 密道 | +| `audio/bgm/bgm_final.mp3` | 🟨 | TBD-音效 | TBD | 魔城 / 天守阁 | +| `audio/bgm/bgm_boss.mp3` | 🟨 | TBD-音效 | TBD | BOSS 战 | +| `audio/bgm/bgm_story.mp3` | 🟨 | TBD-音效 | TBD | 背景介绍过场 | + +### 8.8 交付流程 + +1. 美术 / 音效收到任务后把正式素材放到上表对应路径,**覆盖同名占位文件**。 +2. 在本 md 中把对应行的 🟨 改为 🟩,填写负责人与交付日期。 +3. 提交 PR,CI 会对比文件大小与 `ASSETS.md` 预算表 (§ "体积预算对照")。 +4. 所有条目变 🟩 且体积预算达标后,可以撤掉占位脚本的调用。 + +> **重要**:禁止手动修改 `scripts/gen_placeholder_assets.js` 的输出策略,除非变更点已同步到本 md 的 "当前状态" 段。任何新增资源条目必须**同时**:在上述 § 1~§ 7 规格表中加一行、在 § 8 跟踪表加一行、在脚本 `SPEC` 数组加一条。 diff --git a/assets/resources/ASSETS.md.meta b/assets/resources/ASSETS.md.meta new file mode 100644 index 0000000..4ccb58c --- /dev/null +++ b/assets/resources/ASSETS.md.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.0.1", + "importer": "text", + "imported": true, + "uuid": "b5113d5a-3e2b-4283-a00e-101bdd639f60", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/README.md b/assets/resources/README.md new file mode 100644 index 0000000..091db49 --- /dev/null +++ b/assets/resources/README.md @@ -0,0 +1,16 @@ +# `assets/resources` + +Runtime-loadable resource bundle. Cocos Creator treats everything under +`resources/` as eligible for `resources.load()`/`loadBundle()` APIs. + +``` +resources/ +├── prefabs/ # reusable prefabs (player, enemies, bullets, particles) +├── textures/ # atlas textures, sprite sheets (forest/castle/mocastle) +├── audio/ # bgm (mp3) and sfx (wav) — total ≤ 500KB (req 16.5) +└── configs/ # JSON config tables (enemies, levels, items, story) +``` + +First-package footprint must stay ≤ 4MB (req 18.7). Non-chapter-1 resources +are expected to sit under `assets/resources/chapter2/`, `chapter3/` etc. as +**remote sub-packages** once those chapters are implemented (out of MVP). diff --git a/assets/resources/README.md.meta b/assets/resources/README.md.meta new file mode 100644 index 0000000..0920806 --- /dev/null +++ b/assets/resources/README.md.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.0.1", + "importer": "text", + "imported": true, + "uuid": "d300856f-d174-4c63-b0e7-04ca37e8b365", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/audio.meta b/assets/resources/audio.meta new file mode 100644 index 0000000..353e599 --- /dev/null +++ b/assets/resources/audio.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "927f10ea-97b1-40d0-999c-ca7f05e72dc9", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/audio/.gitkeep b/assets/resources/audio/.gitkeep new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/assets/resources/audio/.gitkeep @@ -0,0 +1 @@ +placeholder diff --git a/assets/resources/audio/bgm.meta b/assets/resources/audio/bgm.meta new file mode 100644 index 0000000..3ed7f87 --- /dev/null +++ b/assets/resources/audio/bgm.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "9afaa92a-33b8-45de-acc1-573fdf6295c4", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/audio/bgm/bgm_boss.mp3 b/assets/resources/audio/bgm/bgm_boss.mp3 new file mode 100644 index 0000000..8eeecaf Binary files /dev/null and b/assets/resources/audio/bgm/bgm_boss.mp3 differ diff --git a/assets/resources/audio/bgm/bgm_boss.mp3.meta b/assets/resources/audio/bgm/bgm_boss.mp3.meta new file mode 100644 index 0000000..f2855f7 --- /dev/null +++ b/assets/resources/audio/bgm/bgm_boss.mp3.meta @@ -0,0 +1,14 @@ +{ + "ver": "1.0.0", + "importer": "audio-clip", + "imported": true, + "uuid": "c5ce754c-9dfd-4978-8c84-a659d7288cbc", + "files": [ + ".json", + ".mp3" + ], + "subMetas": {}, + "userData": { + "downloadMode": 0 + } +} diff --git a/assets/resources/audio/bgm/bgm_castle.mp3 b/assets/resources/audio/bgm/bgm_castle.mp3 new file mode 100644 index 0000000..8eeecaf Binary files /dev/null and b/assets/resources/audio/bgm/bgm_castle.mp3 differ diff --git a/assets/resources/audio/bgm/bgm_castle.mp3.meta b/assets/resources/audio/bgm/bgm_castle.mp3.meta new file mode 100644 index 0000000..0346451 --- /dev/null +++ b/assets/resources/audio/bgm/bgm_castle.mp3.meta @@ -0,0 +1,14 @@ +{ + "ver": "1.0.0", + "importer": "audio-clip", + "imported": true, + "uuid": "4da25ac3-8195-429c-8ef8-40eea51f3016", + "files": [ + ".json", + ".mp3" + ], + "subMetas": {}, + "userData": { + "downloadMode": 0 + } +} diff --git a/assets/resources/audio/bgm/bgm_final.mp3 b/assets/resources/audio/bgm/bgm_final.mp3 new file mode 100644 index 0000000..8eeecaf Binary files /dev/null and b/assets/resources/audio/bgm/bgm_final.mp3 differ diff --git a/assets/resources/audio/bgm/bgm_final.mp3.meta b/assets/resources/audio/bgm/bgm_final.mp3.meta new file mode 100644 index 0000000..218c935 --- /dev/null +++ b/assets/resources/audio/bgm/bgm_final.mp3.meta @@ -0,0 +1,14 @@ +{ + "ver": "1.0.0", + "importer": "audio-clip", + "imported": true, + "uuid": "73db69f8-a83b-40ff-bf70-98e6be93796d", + "files": [ + ".json", + ".mp3" + ], + "subMetas": {}, + "userData": { + "downloadMode": 0 + } +} diff --git a/assets/resources/audio/bgm/bgm_forest.mp3 b/assets/resources/audio/bgm/bgm_forest.mp3 new file mode 100644 index 0000000..8eeecaf Binary files /dev/null and b/assets/resources/audio/bgm/bgm_forest.mp3 differ diff --git a/assets/resources/audio/bgm/bgm_forest.mp3.meta b/assets/resources/audio/bgm/bgm_forest.mp3.meta new file mode 100644 index 0000000..67425df --- /dev/null +++ b/assets/resources/audio/bgm/bgm_forest.mp3.meta @@ -0,0 +1,14 @@ +{ + "ver": "1.0.0", + "importer": "audio-clip", + "imported": true, + "uuid": "ff9696d6-d510-4eae-8e54-f9ea5da08b37", + "files": [ + ".json", + ".mp3" + ], + "subMetas": {}, + "userData": { + "downloadMode": 0 + } +} diff --git a/assets/resources/audio/bgm/bgm_story.mp3 b/assets/resources/audio/bgm/bgm_story.mp3 new file mode 100644 index 0000000..8eeecaf Binary files /dev/null and b/assets/resources/audio/bgm/bgm_story.mp3 differ diff --git a/assets/resources/audio/bgm/bgm_story.mp3.meta b/assets/resources/audio/bgm/bgm_story.mp3.meta new file mode 100644 index 0000000..0b35095 --- /dev/null +++ b/assets/resources/audio/bgm/bgm_story.mp3.meta @@ -0,0 +1,14 @@ +{ + "ver": "1.0.0", + "importer": "audio-clip", + "imported": true, + "uuid": "8090cbeb-a517-4ac8-859b-eda3d2c64776", + "files": [ + ".json", + ".mp3" + ], + "subMetas": {}, + "userData": { + "downloadMode": 0 + } +} diff --git a/assets/resources/audio/sfx.meta b/assets/resources/audio/sfx.meta new file mode 100644 index 0000000..f49d164 --- /dev/null +++ b/assets/resources/audio/sfx.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "920dc5ab-ac41-4189-99e0-fbb006c9760e", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/audio/sfx/attack.wav b/assets/resources/audio/sfx/attack.wav new file mode 100644 index 0000000..7f710a5 Binary files /dev/null and b/assets/resources/audio/sfx/attack.wav differ diff --git a/assets/resources/audio/sfx/attack.wav.meta b/assets/resources/audio/sfx/attack.wav.meta new file mode 100644 index 0000000..bb26943 --- /dev/null +++ b/assets/resources/audio/sfx/attack.wav.meta @@ -0,0 +1,14 @@ +{ + "ver": "1.0.0", + "importer": "audio-clip", + "imported": true, + "uuid": "7ed4c3db-90b6-4d64-b275-ad9b46aededa", + "files": [ + ".json", + ".wav" + ], + "subMetas": {}, + "userData": { + "downloadMode": 0 + } +} diff --git a/assets/resources/audio/sfx/hurt.wav b/assets/resources/audio/sfx/hurt.wav new file mode 100644 index 0000000..7f710a5 Binary files /dev/null and b/assets/resources/audio/sfx/hurt.wav differ diff --git a/assets/resources/audio/sfx/hurt.wav.meta b/assets/resources/audio/sfx/hurt.wav.meta new file mode 100644 index 0000000..8577ca6 --- /dev/null +++ b/assets/resources/audio/sfx/hurt.wav.meta @@ -0,0 +1,14 @@ +{ + "ver": "1.0.0", + "importer": "audio-clip", + "imported": true, + "uuid": "077b06f9-7ab5-4371-b14e-3158ca37db8a", + "files": [ + ".json", + ".wav" + ], + "subMetas": {}, + "userData": { + "downloadMode": 0 + } +} diff --git a/assets/resources/audio/sfx/jump.wav b/assets/resources/audio/sfx/jump.wav new file mode 100644 index 0000000..7f710a5 Binary files /dev/null and b/assets/resources/audio/sfx/jump.wav differ diff --git a/assets/resources/audio/sfx/jump.wav.meta b/assets/resources/audio/sfx/jump.wav.meta new file mode 100644 index 0000000..bea7c83 --- /dev/null +++ b/assets/resources/audio/sfx/jump.wav.meta @@ -0,0 +1,14 @@ +{ + "ver": "1.0.0", + "importer": "audio-clip", + "imported": true, + "uuid": "9910821c-f6ec-4e20-9823-0fa88c21df72", + "files": [ + ".json", + ".wav" + ], + "subMetas": {}, + "userData": { + "downloadMode": 0 + } +} diff --git a/assets/resources/audio/sfx/parry.wav b/assets/resources/audio/sfx/parry.wav new file mode 100644 index 0000000..7f710a5 Binary files /dev/null and b/assets/resources/audio/sfx/parry.wav differ diff --git a/assets/resources/audio/sfx/parry.wav.meta b/assets/resources/audio/sfx/parry.wav.meta new file mode 100644 index 0000000..97adc85 --- /dev/null +++ b/assets/resources/audio/sfx/parry.wav.meta @@ -0,0 +1,14 @@ +{ + "ver": "1.0.0", + "importer": "audio-clip", + "imported": true, + "uuid": "52e8e0bb-29ad-4976-b07d-dcad6a65b737", + "files": [ + ".json", + ".wav" + ], + "subMetas": {}, + "userData": { + "downloadMode": 0 + } +} diff --git a/assets/resources/audio/sfx/pickup.wav b/assets/resources/audio/sfx/pickup.wav new file mode 100644 index 0000000..7f710a5 Binary files /dev/null and b/assets/resources/audio/sfx/pickup.wav differ diff --git a/assets/resources/audio/sfx/pickup.wav.meta b/assets/resources/audio/sfx/pickup.wav.meta new file mode 100644 index 0000000..5c58120 --- /dev/null +++ b/assets/resources/audio/sfx/pickup.wav.meta @@ -0,0 +1,14 @@ +{ + "ver": "1.0.0", + "importer": "audio-clip", + "imported": true, + "uuid": "d2fd68ff-1b8d-4e3a-9147-c52b35625d75", + "files": [ + ".json", + ".wav" + ], + "subMetas": {}, + "userData": { + "downloadMode": 0 + } +} diff --git a/assets/resources/configs.meta b/assets/resources/configs.meta new file mode 100644 index 0000000..07acc36 --- /dev/null +++ b/assets/resources/configs.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "87e31424-a7a9-48da-82c5-8c6e44abaeaf", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/configs/.gitkeep b/assets/resources/configs/.gitkeep new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/assets/resources/configs/.gitkeep @@ -0,0 +1 @@ +placeholder diff --git a/assets/resources/configs/bosses.json b/assets/resources/configs/bosses.json new file mode 100644 index 0000000..54247b6 --- /dev/null +++ b/assets/resources/configs/bosses.json @@ -0,0 +1,14 @@ +[ + { + "id": "shuang_huan_fang", + "displayName": "双幻坊", + "hp": 3, + "butterflyReveal": true, + "princessCutsceneAtHpRatio": 0.5, + "phases": [ + { "hpThreshold": 1.0, "mode": "pair_pincer", "actionIntervalSec": 2.2 }, + { "hpThreshold": 0.66, "mode": "fireball_spread", "actionIntervalSec": 1.8 }, + { "hpThreshold": 0.33, "mode": "clone_confuse", "actionIntervalSec": 1.4 } + ] + } +] diff --git a/assets/resources/configs/bosses.json.meta b/assets/resources/configs/bosses.json.meta new file mode 100644 index 0000000..59ca62e --- /dev/null +++ b/assets/resources/configs/bosses.json.meta @@ -0,0 +1,11 @@ +{ + "ver": "2.0.1", + "importer": "json", + "imported": true, + "uuid": "8e13e1cc-c9ca-440e-8fa2-3d52b15e1688", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/configs/enemies.json b/assets/resources/configs/enemies.json new file mode 100644 index 0000000..2f9443f --- /dev/null +++ b/assets/resources/configs/enemies.json @@ -0,0 +1,48 @@ +[ + { + "id": "qing_ren", + "displayName": "青忍", + "size": { "w": 16, "h": 16 }, + "moveSpeed": 60, + "attackIntervalSec": 2.0, + "attacks": ["shuriken", "sword"], + "hp": 1, + "drops": [] + }, + { + "id": "chi_ren", + "displayName": "赤忍", + "size": { "w": 16, "h": 16 }, + "moveSpeed": 120, + "attackIntervalSec": 1.5, + "attacks": ["smoke_bomb"], + "hp": 1, + "drops": [ + { "item": "dian_wan", "afterKills": 3, "probability": 0.25 }, + { "item": "shu_wan", "afterKills": 3, "probability": 0.25 } + ] + }, + { + "id": "hei_ren", + "displayName": "黑忍", + "size": { "w": 20, "h": 24 }, + "moveSpeed": 80, + "attackIntervalSec": 2.5, + "attacks": ["sword", "shuriken"], + "hp": 2, + "drops": [ + { "item": "mo_di", "probability": 1.0 } + ] + }, + { + "id": "yao_fang", + "displayName": "妖坊", + "size": { "w": 18, "h": 20 }, + "moveSpeed": 0, + "attackIntervalSec": 3.0, + "attacks": ["fireball"], + "hp": 1, + "killObjective": 3, + "drops": [] + } +] diff --git a/assets/resources/configs/enemies.json.meta b/assets/resources/configs/enemies.json.meta new file mode 100644 index 0000000..d7f5c1c --- /dev/null +++ b/assets/resources/configs/enemies.json.meta @@ -0,0 +1,11 @@ +{ + "ver": "2.0.1", + "importer": "json", + "imported": true, + "uuid": "ffaf0523-84a3-4c88-97a3-fb14b41c07cc", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/configs/items.json b/assets/resources/configs/items.json new file mode 100644 index 0000000..f2f0c5a --- /dev/null +++ b/assets/resources/configs/items.json @@ -0,0 +1,36 @@ +[ + { + "id": "crystal_jade", + "displayName": "水晶玉", + "icon": "items/crystal_jade", + "lifetimeSec": 16 + }, + { + "id": "dian_wan", + "displayName": "点丸", + "icon": "items/dian_wan", + "durationSec": 30, + "magnitude": 0.5, + "lifetimeSec": 20 + }, + { + "id": "shu_wan", + "displayName": "术丸", + "icon": "items/shu_wan", + "durationSec": 20, + "magnitude": 0.3, + "lifetimeSec": 20 + }, + { + "id": "mo_di", + "displayName": "魔笛", + "icon": "items/mo_di", + "lifetimeSec": 60 + }, + { + "id": "zeng_wan", + "displayName": "增丸", + "icon": "items/zeng_wan", + "lifetimeSec": 30 + } +] diff --git a/assets/resources/configs/items.json.meta b/assets/resources/configs/items.json.meta new file mode 100644 index 0000000..9804548 --- /dev/null +++ b/assets/resources/configs/items.json.meta @@ -0,0 +1,11 @@ +{ + "ver": "2.0.1", + "importer": "json", + "imported": true, + "uuid": "c58ff9ce-2645-44ff-8df9-4f9bfc68b585", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/configs/levels.json b/assets/resources/configs/levels.json new file mode 100644 index 0000000..a45c448 --- /dev/null +++ b/assets/resources/configs/levels.json @@ -0,0 +1,84 @@ +[ + { + "id": "1-1", + "chapter": 1, + "displayName": "初始森林", + "sceneTheme": "forest", + "scrollDirection": "horizontal", + "timeLimitSec": 75, + "objective": { "kind": "kill_count", "enemy": "yao_fang", "count": 3 }, + "levelLengthPx": 3840, + "bgm": "bgm_forest", + "enemySpawns": [ + { "type": "yao_fang", "atPx": 900 }, + { "type": "qing_ren", "atPx": 1400, "count": 2 }, + { "type": "yao_fang", "atPx": 2100 }, + { "type": "chi_ren", "atPx": 2600, "count": 2 }, + { "type": "yao_fang", "atPx": 3200 } + ] + }, + { + "id": "1-2", + "chapter": 1, + "displayName": "森林深处", + "sceneTheme": "forest", + "scrollDirection": "horizontal", + "timeLimitSec": 85, + "objective": { "kind": "kill_count", "enemy": "yao_fang", "count": 1 }, + "levelLengthPx": 4200, + "bgm": "bgm_forest", + "enemySpawns": [ + { "type": "qing_ren", "atPx": 800, "count": 2 }, + { "type": "chi_ren", "atPx": 1600, "count": 3 }, + { "type": "qing_ren", "atPx": 2400, "count": 2 }, + { "type": "yao_fang", "atPx": 3600 } + ] + }, + { + "id": "1-3", + "chapter": 1, + "displayName": "洞穴水路", + "sceneTheme": "cave", + "scrollDirection": "horizontal_bi", + "timeLimitSec": 100, + "objective": { "kind": "kill_count", "enemy": "qing_ren", "count": 10 }, + "levelLengthPx": 4800, + "bgm": "bgm_forest", + "enemySpawns": [ + { "type": "qing_ren", "atPx": 600, "count": 2 }, + { "type": "qing_ren", "atPx": 1400, "count": 3 }, + { "type": "chi_ren", "atPx": 2200, "count": 1 }, + { "type": "qing_ren", "atPx": 3000, "count": 3 }, + { "type": "qing_ren", "atPx": 4000, "count": 2 } + ] + }, + { + "id": "1-4", + "chapter": 1, + "displayName": "城壁", + "sceneTheme": "castle_wall", + "scrollDirection": "vertical", + "timeLimitSec": 95, + "objective": { "kind": "reach_top" }, + "levelLengthPx": 3240, + "bgm": "bgm_castle", + "enemySpawns": [ + { "type": "hei_ren", "atPx": 800 }, + { "type": "qing_ren", "atPx": 1400, "count": 2 }, + { "type": "hei_ren", "atPx": 2000 }, + { "type": "chi_ren", "atPx": 2600, "count": 2 } + ] + }, + { + "id": "1-5", + "chapter": 1, + "displayName": "魔城天守阁", + "sceneTheme": "demon_castle", + "scrollDirection": "horizontal", + "timeLimitSec": 120, + "objective": { "kind": "defeat_boss", "bossId": "shuang_huan_fang" }, + "levelLengthPx": 1920, + "bgm": "bgm_boss", + "enemySpawns": [] + } +] diff --git a/assets/resources/configs/levels.json.meta b/assets/resources/configs/levels.json.meta new file mode 100644 index 0000000..10b093b --- /dev/null +++ b/assets/resources/configs/levels.json.meta @@ -0,0 +1,11 @@ +{ + "ver": "2.0.1", + "importer": "json", + "imported": true, + "uuid": "0e151ff6-b2d3-4841-abd6-8286943549d9", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/configs/stories.json b/assets/resources/configs/stories.json new file mode 100644 index 0000000..ce447ac --- /dev/null +++ b/assets/resources/configs/stories.json @@ -0,0 +1,24 @@ +[ + { +"id": "chapter_1_intro", + "bgm": "bgm_story", + "maxDurationSec": 30, + "pages": [ + { + "index": 1, + "illustration": "story/ch1_page1_ninja", + "text": "在月影摇曳的古国,有一位代代相传的忍者——影。他身着赤红忍装,精通手里剑与忍者刀,守护着这片宁静的土地。" + }, + { + "index": 2, + "illustration": "story/ch1_page2_princess", + "text": "然而在一个暴雨之夜,青忍的黑影撕开了宫殿的夜幕,公主被青忍的爪牙掳走,消失在魔城方向的天际。" + }, + { + "index": 3, + "illustration": "story/ch1_page3_depart", + "text": "为了将公主从邪恶的双幻坊手中救出,影踏上了穿越森林、洞穴、城壁、直入魔城天守阁的征程。" + } + ] + } +] diff --git a/assets/resources/configs/stories.json.meta b/assets/resources/configs/stories.json.meta new file mode 100644 index 0000000..0ae76cd --- /dev/null +++ b/assets/resources/configs/stories.json.meta @@ -0,0 +1,11 @@ +{ + "ver": "2.0.1", + "importer": "json", + "imported": true, + "uuid": "3f7c2a22-337f-4729-ab7e-7e0655527ef3", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/configs/weapons.json b/assets/resources/configs/weapons.json new file mode 100644 index 0000000..34e9dfb --- /dev/null +++ b/assets/resources/configs/weapons.json @@ -0,0 +1,18 @@ +[ + { + "id": "shuriken", + "displayName": "手里剑", + "baseIntervalSec": 0.3, + "upgradedIntervalSec": 0.25, + "damage": 1, + "canParry": false, + "burstMax": 3 + }, + { + "id": "ninja_sword", + "displayName": "忍者刀", + "baseIntervalSec": 0.5, + "damage": 2, + "canParry": true + } +] diff --git a/assets/resources/configs/weapons.json.meta b/assets/resources/configs/weapons.json.meta new file mode 100644 index 0000000..8308472 --- /dev/null +++ b/assets/resources/configs/weapons.json.meta @@ -0,0 +1,11 @@ +{ + "ver": "2.0.1", + "importer": "json", + "imported": true, + "uuid": "765a1200-970d-4b01-bbb2-dde9fa04929d", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/prefabs.meta b/assets/resources/prefabs.meta new file mode 100644 index 0000000..664fa9e --- /dev/null +++ b/assets/resources/prefabs.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "4bb74ef5-e22c-4697-ab1f-2e553875fe91", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/prefabs/.gitkeep b/assets/resources/prefabs/.gitkeep new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/assets/resources/prefabs/.gitkeep @@ -0,0 +1 @@ +placeholder diff --git a/assets/resources/textures.meta b/assets/resources/textures.meta new file mode 100644 index 0000000..96e7321 --- /dev/null +++ b/assets/resources/textures.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "4ea0d7e3-e3cf-4adc-80ac-eb105b6bb80c", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/textures/.gitkeep b/assets/resources/textures/.gitkeep new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/assets/resources/textures/.gitkeep @@ -0,0 +1 @@ +placeholder diff --git a/assets/resources/textures/bosses.meta b/assets/resources/textures/bosses.meta new file mode 100644 index 0000000..580b501 --- /dev/null +++ b/assets/resources/textures/bosses.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "3b4b7c86-7063-47e1-884d-6fb9d29b0c3c", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/textures/bosses/butterfly.png b/assets/resources/textures/bosses/butterfly.png new file mode 100644 index 0000000..0157b90 Binary files /dev/null and b/assets/resources/textures/bosses/butterfly.png differ diff --git a/assets/resources/textures/bosses/butterfly.png.meta b/assets/resources/textures/bosses/butterfly.png.meta new file mode 100644 index 0000000..4860734 --- /dev/null +++ b/assets/resources/textures/bosses/butterfly.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "b8c5db2b-1d4f-4ade-9f4d-2c0da1d21f9e", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "b8c5db2b-1d4f-4ade-9f4d-2c0da1d21f9e@6c48a", + "displayName": "butterfly", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "b8c5db2b-1d4f-4ade-9f4d-2c0da1d21f9e", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "b8c5db2b-1d4f-4ade-9f4d-2c0da1d21f9e@6c48a" + } +} diff --git a/assets/resources/textures/bosses/shuang_huan_fang.png b/assets/resources/textures/bosses/shuang_huan_fang.png new file mode 100644 index 0000000..b95c9fa Binary files /dev/null and b/assets/resources/textures/bosses/shuang_huan_fang.png differ diff --git a/assets/resources/textures/bosses/shuang_huan_fang.png.meta b/assets/resources/textures/bosses/shuang_huan_fang.png.meta new file mode 100644 index 0000000..e81b99c --- /dev/null +++ b/assets/resources/textures/bosses/shuang_huan_fang.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "30ebfe50-e7e5-4c59-9d41-a23906d2406c", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "30ebfe50-e7e5-4c59-9d41-a23906d2406c@6c48a", + "displayName": "shuang_huan_fang", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "30ebfe50-e7e5-4c59-9d41-a23906d2406c", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "30ebfe50-e7e5-4c59-9d41-a23906d2406c@6c48a" + } +} diff --git a/assets/resources/textures/characters.meta b/assets/resources/textures/characters.meta new file mode 100644 index 0000000..afcf7c7 --- /dev/null +++ b/assets/resources/textures/characters.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "0faecf46-eead-43f9-bc67-b33687d6a372", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/textures/characters/kage_green.png b/assets/resources/textures/characters/kage_green.png new file mode 100644 index 0000000..6b5bfd2 Binary files /dev/null and b/assets/resources/textures/characters/kage_green.png differ diff --git a/assets/resources/textures/characters/kage_green.png.meta b/assets/resources/textures/characters/kage_green.png.meta new file mode 100644 index 0000000..2b484b4 --- /dev/null +++ b/assets/resources/textures/characters/kage_green.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "de17d584-ed54-49ec-a1a5-de351ecb6e4d", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "de17d584-ed54-49ec-a1a5-de351ecb6e4d@6c48a", + "displayName": "kage_green", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "de17d584-ed54-49ec-a1a5-de351ecb6e4d", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "de17d584-ed54-49ec-a1a5-de351ecb6e4d@6c48a" + } +} diff --git a/assets/resources/textures/characters/kage_red.png b/assets/resources/textures/characters/kage_red.png new file mode 100644 index 0000000..9c4b7fd Binary files /dev/null and b/assets/resources/textures/characters/kage_red.png differ diff --git a/assets/resources/textures/characters/kage_red.png.meta b/assets/resources/textures/characters/kage_red.png.meta new file mode 100644 index 0000000..d2f917d --- /dev/null +++ b/assets/resources/textures/characters/kage_red.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "1ea27d49-1512-48e8-b3d4-3636a93c07a3", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "1ea27d49-1512-48e8-b3d4-3636a93c07a3@6c48a", + "displayName": "kage_red", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "1ea27d49-1512-48e8-b3d4-3636a93c07a3", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "1ea27d49-1512-48e8-b3d4-3636a93c07a3@6c48a" + } +} diff --git a/assets/resources/textures/characters/kage_yellow.png b/assets/resources/textures/characters/kage_yellow.png new file mode 100644 index 0000000..fc027be Binary files /dev/null and b/assets/resources/textures/characters/kage_yellow.png differ diff --git a/assets/resources/textures/characters/kage_yellow.png.meta b/assets/resources/textures/characters/kage_yellow.png.meta new file mode 100644 index 0000000..3a1448c --- /dev/null +++ b/assets/resources/textures/characters/kage_yellow.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "cc33a404-518b-4d7a-9699-765d88256b1f", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "cc33a404-518b-4d7a-9699-765d88256b1f@6c48a", + "displayName": "kage_yellow", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "cc33a404-518b-4d7a-9699-765d88256b1f", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "cc33a404-518b-4d7a-9699-765d88256b1f@6c48a" + } +} diff --git a/assets/resources/textures/enemies.meta b/assets/resources/textures/enemies.meta new file mode 100644 index 0000000..5017602 --- /dev/null +++ b/assets/resources/textures/enemies.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "afa56727-5ae0-4568-806f-1496179a4da3", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/textures/enemies/chi_ren.png b/assets/resources/textures/enemies/chi_ren.png new file mode 100644 index 0000000..15917d1 Binary files /dev/null and b/assets/resources/textures/enemies/chi_ren.png differ diff --git a/assets/resources/textures/enemies/chi_ren.png.meta b/assets/resources/textures/enemies/chi_ren.png.meta new file mode 100644 index 0000000..af6dd64 --- /dev/null +++ b/assets/resources/textures/enemies/chi_ren.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "36016f50-9022-4662-9b78-b9b29ba64510", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "36016f50-9022-4662-9b78-b9b29ba64510@6c48a", + "displayName": "chi_ren", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "36016f50-9022-4662-9b78-b9b29ba64510", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "36016f50-9022-4662-9b78-b9b29ba64510@6c48a" + } +} diff --git a/assets/resources/textures/enemies/hei_ren.png b/assets/resources/textures/enemies/hei_ren.png new file mode 100644 index 0000000..5cfd3dd Binary files /dev/null and b/assets/resources/textures/enemies/hei_ren.png differ diff --git a/assets/resources/textures/enemies/hei_ren.png.meta b/assets/resources/textures/enemies/hei_ren.png.meta new file mode 100644 index 0000000..28a48f8 --- /dev/null +++ b/assets/resources/textures/enemies/hei_ren.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "acfb19f8-ac1b-445f-8f81-fac6aa4071c8", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "acfb19f8-ac1b-445f-8f81-fac6aa4071c8@6c48a", + "displayName": "hei_ren", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "acfb19f8-ac1b-445f-8f81-fac6aa4071c8", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "acfb19f8-ac1b-445f-8f81-fac6aa4071c8@6c48a" + } +} diff --git a/assets/resources/textures/enemies/qing_ren.png b/assets/resources/textures/enemies/qing_ren.png new file mode 100644 index 0000000..ed50e60 Binary files /dev/null and b/assets/resources/textures/enemies/qing_ren.png differ diff --git a/assets/resources/textures/enemies/qing_ren.png.meta b/assets/resources/textures/enemies/qing_ren.png.meta new file mode 100644 index 0000000..29d67e7 --- /dev/null +++ b/assets/resources/textures/enemies/qing_ren.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "c5261dd4-ea58-49eb-88ff-7c864104b499", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "c5261dd4-ea58-49eb-88ff-7c864104b499@6c48a", + "displayName": "qing_ren", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "c5261dd4-ea58-49eb-88ff-7c864104b499", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "c5261dd4-ea58-49eb-88ff-7c864104b499@6c48a" + } +} diff --git a/assets/resources/textures/enemies/yao_fang.png b/assets/resources/textures/enemies/yao_fang.png new file mode 100644 index 0000000..d52841c Binary files /dev/null and b/assets/resources/textures/enemies/yao_fang.png differ diff --git a/assets/resources/textures/enemies/yao_fang.png.meta b/assets/resources/textures/enemies/yao_fang.png.meta new file mode 100644 index 0000000..9d55458 --- /dev/null +++ b/assets/resources/textures/enemies/yao_fang.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "1c138e9b-5973-4b83-b632-dd13e00f5429", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "1c138e9b-5973-4b83-b632-dd13e00f5429@6c48a", + "displayName": "yao_fang", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "1c138e9b-5973-4b83-b632-dd13e00f5429", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "1c138e9b-5973-4b83-b632-dd13e00f5429@6c48a" + } +} diff --git a/assets/resources/textures/fx.meta b/assets/resources/textures/fx.meta new file mode 100644 index 0000000..51e0253 --- /dev/null +++ b/assets/resources/textures/fx.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "4c5e61ae-47f7-4189-94b3-d1cef76d32e5", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/textures/fx/jump_dust.png b/assets/resources/textures/fx/jump_dust.png new file mode 100644 index 0000000..e44804c Binary files /dev/null and b/assets/resources/textures/fx/jump_dust.png differ diff --git a/assets/resources/textures/fx/jump_dust.png.meta b/assets/resources/textures/fx/jump_dust.png.meta new file mode 100644 index 0000000..33ff811 --- /dev/null +++ b/assets/resources/textures/fx/jump_dust.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "9ca79ef0-d218-4ec6-9d27-0af065e6bcfd", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "9ca79ef0-d218-4ec6-9d27-0af065e6bcfd@6c48a", + "displayName": "jump_dust", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "9ca79ef0-d218-4ec6-9d27-0af065e6bcfd", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "9ca79ef0-d218-4ec6-9d27-0af065e6bcfd@6c48a" + } +} diff --git a/assets/resources/textures/fx/leaf_particle.png b/assets/resources/textures/fx/leaf_particle.png new file mode 100644 index 0000000..af5c506 Binary files /dev/null and b/assets/resources/textures/fx/leaf_particle.png differ diff --git a/assets/resources/textures/fx/leaf_particle.png.meta b/assets/resources/textures/fx/leaf_particle.png.meta new file mode 100644 index 0000000..9b76528 --- /dev/null +++ b/assets/resources/textures/fx/leaf_particle.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "6ff7aee5-e8fd-4a7a-89bc-95598bfbcbac", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "6ff7aee5-e8fd-4a7a-89bc-95598bfbcbac@6c48a", + "displayName": "leaf_particle", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "6ff7aee5-e8fd-4a7a-89bc-95598bfbcbac", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "6ff7aee5-e8fd-4a7a-89bc-95598bfbcbac@6c48a" + } +} diff --git a/assets/resources/textures/fx/parry_spark.png b/assets/resources/textures/fx/parry_spark.png new file mode 100644 index 0000000..d64a198 Binary files /dev/null and b/assets/resources/textures/fx/parry_spark.png differ diff --git a/assets/resources/textures/fx/parry_spark.png.meta b/assets/resources/textures/fx/parry_spark.png.meta new file mode 100644 index 0000000..57a96b8 --- /dev/null +++ b/assets/resources/textures/fx/parry_spark.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "adfcaead-eec0-4a25-b69c-571a34154af1", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "adfcaead-eec0-4a25-b69c-571a34154af1@6c48a", + "displayName": "parry_spark", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "adfcaead-eec0-4a25-b69c-571a34154af1", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "adfcaead-eec0-4a25-b69c-571a34154af1@6c48a" + } +} diff --git a/assets/resources/textures/scenes.meta b/assets/resources/textures/scenes.meta new file mode 100644 index 0000000..e100d59 --- /dev/null +++ b/assets/resources/textures/scenes.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "e91af575-ddd2-4f03-8ed0-4a647413c04c", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/textures/scenes/castle_wall.meta b/assets/resources/textures/scenes/castle_wall.meta new file mode 100644 index 0000000..76fe6e0 --- /dev/null +++ b/assets/resources/textures/scenes/castle_wall.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "1a871e24-00e0-4ff0-a2ab-22aff7fa9a97", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/textures/scenes/castle_wall/far.png b/assets/resources/textures/scenes/castle_wall/far.png new file mode 100644 index 0000000..dbcce41 Binary files /dev/null and b/assets/resources/textures/scenes/castle_wall/far.png differ diff --git a/assets/resources/textures/scenes/castle_wall/far.png.meta b/assets/resources/textures/scenes/castle_wall/far.png.meta new file mode 100644 index 0000000..4266f53 --- /dev/null +++ b/assets/resources/textures/scenes/castle_wall/far.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "58461a49-befa-4fa6-9a0c-3a4a2780a05e", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "58461a49-befa-4fa6-9a0c-3a4a2780a05e@6c48a", + "displayName": "far", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "58461a49-befa-4fa6-9a0c-3a4a2780a05e", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "58461a49-befa-4fa6-9a0c-3a4a2780a05e@6c48a" + } +} diff --git a/assets/resources/textures/scenes/castle_wall/fx.png b/assets/resources/textures/scenes/castle_wall/fx.png new file mode 100644 index 0000000..3641cc6 Binary files /dev/null and b/assets/resources/textures/scenes/castle_wall/fx.png differ diff --git a/assets/resources/textures/scenes/castle_wall/fx.png.meta b/assets/resources/textures/scenes/castle_wall/fx.png.meta new file mode 100644 index 0000000..71ab6c2 --- /dev/null +++ b/assets/resources/textures/scenes/castle_wall/fx.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "422d5dc6-7148-44f5-889c-f75a00567421", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "422d5dc6-7148-44f5-889c-f75a00567421@6c48a", + "displayName": "fx", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "422d5dc6-7148-44f5-889c-f75a00567421", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "422d5dc6-7148-44f5-889c-f75a00567421@6c48a" + } +} diff --git a/assets/resources/textures/scenes/castle_wall/mid.png b/assets/resources/textures/scenes/castle_wall/mid.png new file mode 100644 index 0000000..da6c50d Binary files /dev/null and b/assets/resources/textures/scenes/castle_wall/mid.png differ diff --git a/assets/resources/textures/scenes/castle_wall/mid.png.meta b/assets/resources/textures/scenes/castle_wall/mid.png.meta new file mode 100644 index 0000000..260b1aa --- /dev/null +++ b/assets/resources/textures/scenes/castle_wall/mid.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "6db47cc3-72a3-4a9b-b100-48148f1cc934", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "6db47cc3-72a3-4a9b-b100-48148f1cc934@6c48a", + "displayName": "mid", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "6db47cc3-72a3-4a9b-b100-48148f1cc934", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "6db47cc3-72a3-4a9b-b100-48148f1cc934@6c48a" + } +} diff --git a/assets/resources/textures/scenes/castle_wall/near.png b/assets/resources/textures/scenes/castle_wall/near.png new file mode 100644 index 0000000..e426f98 Binary files /dev/null and b/assets/resources/textures/scenes/castle_wall/near.png differ diff --git a/assets/resources/textures/scenes/castle_wall/near.png.meta b/assets/resources/textures/scenes/castle_wall/near.png.meta new file mode 100644 index 0000000..3fbb52d --- /dev/null +++ b/assets/resources/textures/scenes/castle_wall/near.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "be8f61cb-4af2-466e-bc79-18e27d2a70f6", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "be8f61cb-4af2-466e-bc79-18e27d2a70f6@6c48a", + "displayName": "near", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "be8f61cb-4af2-466e-bc79-18e27d2a70f6", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "be8f61cb-4af2-466e-bc79-18e27d2a70f6@6c48a" + } +} diff --git a/assets/resources/textures/scenes/demon_castle.meta b/assets/resources/textures/scenes/demon_castle.meta new file mode 100644 index 0000000..5680d3d --- /dev/null +++ b/assets/resources/textures/scenes/demon_castle.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "9aecc425-0b81-49d0-ab61-921d59f01adb", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/textures/scenes/demon_castle/far.png b/assets/resources/textures/scenes/demon_castle/far.png new file mode 100644 index 0000000..596e8a6 Binary files /dev/null and b/assets/resources/textures/scenes/demon_castle/far.png differ diff --git a/assets/resources/textures/scenes/demon_castle/far.png.meta b/assets/resources/textures/scenes/demon_castle/far.png.meta new file mode 100644 index 0000000..28ab9bf --- /dev/null +++ b/assets/resources/textures/scenes/demon_castle/far.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "23a1bcbc-d250-4d63-9a28-738127c71789", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "23a1bcbc-d250-4d63-9a28-738127c71789@6c48a", + "displayName": "far", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "23a1bcbc-d250-4d63-9a28-738127c71789", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "23a1bcbc-d250-4d63-9a28-738127c71789@6c48a" + } +} diff --git a/assets/resources/textures/scenes/demon_castle/fx.png b/assets/resources/textures/scenes/demon_castle/fx.png new file mode 100644 index 0000000..7415a1d Binary files /dev/null and b/assets/resources/textures/scenes/demon_castle/fx.png differ diff --git a/assets/resources/textures/scenes/demon_castle/fx.png.meta b/assets/resources/textures/scenes/demon_castle/fx.png.meta new file mode 100644 index 0000000..7d4f981 --- /dev/null +++ b/assets/resources/textures/scenes/demon_castle/fx.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "7ba84d2b-d60a-42c7-b7d9-4692b2283526", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "7ba84d2b-d60a-42c7-b7d9-4692b2283526@6c48a", + "displayName": "fx", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "7ba84d2b-d60a-42c7-b7d9-4692b2283526", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "7ba84d2b-d60a-42c7-b7d9-4692b2283526@6c48a" + } +} diff --git a/assets/resources/textures/scenes/demon_castle/mid.png b/assets/resources/textures/scenes/demon_castle/mid.png new file mode 100644 index 0000000..624e20d Binary files /dev/null and b/assets/resources/textures/scenes/demon_castle/mid.png differ diff --git a/assets/resources/textures/scenes/demon_castle/mid.png.meta b/assets/resources/textures/scenes/demon_castle/mid.png.meta new file mode 100644 index 0000000..c45bab7 --- /dev/null +++ b/assets/resources/textures/scenes/demon_castle/mid.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "db901ada-5c31-4820-b0bd-1990341ac44f", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "db901ada-5c31-4820-b0bd-1990341ac44f@6c48a", + "displayName": "mid", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "db901ada-5c31-4820-b0bd-1990341ac44f", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "db901ada-5c31-4820-b0bd-1990341ac44f@6c48a" + } +} diff --git a/assets/resources/textures/scenes/demon_castle/near.png b/assets/resources/textures/scenes/demon_castle/near.png new file mode 100644 index 0000000..3f30bf6 Binary files /dev/null and b/assets/resources/textures/scenes/demon_castle/near.png differ diff --git a/assets/resources/textures/scenes/demon_castle/near.png.meta b/assets/resources/textures/scenes/demon_castle/near.png.meta new file mode 100644 index 0000000..524a93b --- /dev/null +++ b/assets/resources/textures/scenes/demon_castle/near.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "60456a45-0933-4a9f-b8bb-61cbf1761456", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "60456a45-0933-4a9f-b8bb-61cbf1761456@6c48a", + "displayName": "near", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "60456a45-0933-4a9f-b8bb-61cbf1761456", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "60456a45-0933-4a9f-b8bb-61cbf1761456@6c48a" + } +} diff --git a/assets/resources/textures/scenes/forest.meta b/assets/resources/textures/scenes/forest.meta new file mode 100644 index 0000000..bfdfff0 --- /dev/null +++ b/assets/resources/textures/scenes/forest.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "cefd10ad-c062-4d72-a25b-dc31b485618b", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/textures/scenes/forest/far.png b/assets/resources/textures/scenes/forest/far.png new file mode 100644 index 0000000..4b928cf Binary files /dev/null and b/assets/resources/textures/scenes/forest/far.png differ diff --git a/assets/resources/textures/scenes/forest/far.png.meta b/assets/resources/textures/scenes/forest/far.png.meta new file mode 100644 index 0000000..ed09423 --- /dev/null +++ b/assets/resources/textures/scenes/forest/far.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "a0897bc0-02aa-4dc7-a106-e3bb3ee845d6", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "a0897bc0-02aa-4dc7-a106-e3bb3ee845d6@6c48a", + "displayName": "far", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "a0897bc0-02aa-4dc7-a106-e3bb3ee845d6", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "a0897bc0-02aa-4dc7-a106-e3bb3ee845d6@6c48a" + } +} diff --git a/assets/resources/textures/scenes/forest/fx.png b/assets/resources/textures/scenes/forest/fx.png new file mode 100644 index 0000000..7007475 Binary files /dev/null and b/assets/resources/textures/scenes/forest/fx.png differ diff --git a/assets/resources/textures/scenes/forest/fx.png.meta b/assets/resources/textures/scenes/forest/fx.png.meta new file mode 100644 index 0000000..02e234d --- /dev/null +++ b/assets/resources/textures/scenes/forest/fx.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "dd10a162-98e8-4eb0-b51f-f3e85db1dbf2", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "dd10a162-98e8-4eb0-b51f-f3e85db1dbf2@6c48a", + "displayName": "fx", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "dd10a162-98e8-4eb0-b51f-f3e85db1dbf2", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "dd10a162-98e8-4eb0-b51f-f3e85db1dbf2@6c48a" + } +} diff --git a/assets/resources/textures/scenes/forest/mid.png b/assets/resources/textures/scenes/forest/mid.png new file mode 100644 index 0000000..7676df9 Binary files /dev/null and b/assets/resources/textures/scenes/forest/mid.png differ diff --git a/assets/resources/textures/scenes/forest/mid.png.meta b/assets/resources/textures/scenes/forest/mid.png.meta new file mode 100644 index 0000000..acf2e8f --- /dev/null +++ b/assets/resources/textures/scenes/forest/mid.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "a77268f3-adc4-4859-a395-bb20202cccad", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "a77268f3-adc4-4859-a395-bb20202cccad@6c48a", + "displayName": "mid", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "a77268f3-adc4-4859-a395-bb20202cccad", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "a77268f3-adc4-4859-a395-bb20202cccad@6c48a" + } +} diff --git a/assets/resources/textures/scenes/forest/near.png b/assets/resources/textures/scenes/forest/near.png new file mode 100644 index 0000000..e4aac69 Binary files /dev/null and b/assets/resources/textures/scenes/forest/near.png differ diff --git a/assets/resources/textures/scenes/forest/near.png.meta b/assets/resources/textures/scenes/forest/near.png.meta new file mode 100644 index 0000000..a420db1 --- /dev/null +++ b/assets/resources/textures/scenes/forest/near.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "269788c5-eed0-4a3f-b8bf-be14bb4b20f3", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "269788c5-eed0-4a3f-b8bf-be14bb4b20f3@6c48a", + "displayName": "near", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "269788c5-eed0-4a3f-b8bf-be14bb4b20f3", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "269788c5-eed0-4a3f-b8bf-be14bb4b20f3@6c48a" + } +} diff --git a/assets/resources/textures/story.meta b/assets/resources/textures/story.meta new file mode 100644 index 0000000..a591159 --- /dev/null +++ b/assets/resources/textures/story.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "d9de1ee8-883d-47f2-b94b-df9e61464b3e", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/resources/textures/story/ch1_page1_ninja.png b/assets/resources/textures/story/ch1_page1_ninja.png new file mode 100644 index 0000000..6c36b36 Binary files /dev/null and b/assets/resources/textures/story/ch1_page1_ninja.png differ diff --git a/assets/resources/textures/story/ch1_page1_ninja.png.meta b/assets/resources/textures/story/ch1_page1_ninja.png.meta new file mode 100644 index 0000000..653a96c --- /dev/null +++ b/assets/resources/textures/story/ch1_page1_ninja.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "647f87dd-0c4b-48c9-a6ab-b991424c683a", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "647f87dd-0c4b-48c9-a6ab-b991424c683a@6c48a", + "displayName": "ch1_page1_ninja", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "647f87dd-0c4b-48c9-a6ab-b991424c683a", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "647f87dd-0c4b-48c9-a6ab-b991424c683a@6c48a" + } +} diff --git a/assets/resources/textures/story/ch1_page2_princess.png b/assets/resources/textures/story/ch1_page2_princess.png new file mode 100644 index 0000000..3ae4433 Binary files /dev/null and b/assets/resources/textures/story/ch1_page2_princess.png differ diff --git a/assets/resources/textures/story/ch1_page2_princess.png.meta b/assets/resources/textures/story/ch1_page2_princess.png.meta new file mode 100644 index 0000000..d62e444 --- /dev/null +++ b/assets/resources/textures/story/ch1_page2_princess.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "9074a6bf-d546-4e11-bd01-54d848018750", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "9074a6bf-d546-4e11-bd01-54d848018750@6c48a", + "displayName": "ch1_page2_princess", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "9074a6bf-d546-4e11-bd01-54d848018750", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "9074a6bf-d546-4e11-bd01-54d848018750@6c48a" + } +} diff --git a/assets/resources/textures/story/ch1_page3_depart.png b/assets/resources/textures/story/ch1_page3_depart.png new file mode 100644 index 0000000..ed535fb Binary files /dev/null and b/assets/resources/textures/story/ch1_page3_depart.png differ diff --git a/assets/resources/textures/story/ch1_page3_depart.png.meta b/assets/resources/textures/story/ch1_page3_depart.png.meta new file mode 100644 index 0000000..dc6cf2f --- /dev/null +++ b/assets/resources/textures/story/ch1_page3_depart.png.meta @@ -0,0 +1,42 @@ +{ + "ver": "1.0.27", + "importer": "image", + "imported": true, + "uuid": "7f90c5af-45b8-4576-9c13-f40a8eeb00a4", + "files": [ + ".json", + ".png" + ], + "subMetas": { + "6c48a": { + "importer": "texture", + "uuid": "7f90c5af-45b8-4576-9c13-f40a8eeb00a4@6c48a", + "displayName": "ch1_page3_depart", + "id": "6c48a", + "name": "texture", + "userData": { + "wrapModeS": "repeat", + "wrapModeT": "repeat", + "minfilter": "linear", + "magfilter": "linear", + "mipfilter": "none", + "anisotropy": 0, + "isUuid": true, + "imageUuidOrDatabaseUri": "7f90c5af-45b8-4576-9c13-f40a8eeb00a4", + "visible": false + }, + "ver": "1.0.22", + "imported": true, + "files": [ + ".json" + ], + "subMetas": {} + } + }, + "userData": { + "type": "texture", + "fixAlphaTransparencyArtifacts": false, + "hasAlpha": true, + "redirect": "7f90c5af-45b8-4576-9c13-f40a8eeb00a4@6c48a" + } +} diff --git a/assets/scenes.meta b/assets/scenes.meta new file mode 100644 index 0000000..c8db1ff --- /dev/null +++ b/assets/scenes.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "97b5f79e-1ad7-4041-bab4-27222d2e711d", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/Boot.scene b/assets/scenes/Boot.scene new file mode 100644 index 0000000..b258a1a --- /dev/null +++ b/assets/scenes/Boot.scene @@ -0,0 +1,494 @@ +[ + { + "__type__": "cc.SceneAsset", + "_name": "Boot", + "_objFlags": 0, + "__editorExtras__": {}, + "_native": "", + "scene": { + "__id__": 1 + } + }, + { + "__type__": "cc.Scene", + "_name": "Boot", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": null, + "_children": [ + { + "__id__": 2 + }, + { + "__id__": 5 + }, + { + "__id__": 7 + } + ], + "_active": true, + "_components": [], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "autoReleaseAssets": false, + "_globals": { + "__id__": 9 + }, + "_id": "2b3e945f-51bf-42cd-8a0e-74b1ddeeb94d" + }, + { + "__type__": "cc.Node", + "_name": "Main Light", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 3 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.06397656665577071, + "y": -0.44608233363525845, + "z": -0.8239028751062036, + "w": -0.3436591377065261 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -117.894, + "y": -194.909, + "z": 38.562 + }, + "_id": "912lNyCJFM7qU4jNODUz2N" + }, + { + "__type__": "cc.DirectionalLight", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 2 + }, + "_enabled": true, + "__prefab": null, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 250, + "b": 240, + "a": 255 + }, + "_useColorTemperature": false, + "_colorTemperature": 6550, + "_staticSettings": { + "__id__": 4 + }, + "_visibility": -325058561, + "_illuminanceHDR": 65000, + "_illuminance": 65000, + "_illuminanceLDR": 1.6927083333333335, + "_shadowEnabled": false, + "_shadowPcf": 0, + "_shadowBias": 0.00001, + "_shadowNormalBias": 0, + "_shadowSaturation": 1, + "_shadowDistance": 50, + "_shadowInvisibleOcclusionRange": 200, + "_csmLevel": 4, + "_csmLayerLambda": 0.75, + "_csmOptimizationMode": 2, + "_csmAdvancedOptions": false, + "_csmLayersTransition": false, + "_csmTransitionRange": 0.05, + "_shadowFixedArea": false, + "_shadowNear": 0.1, + "_shadowFar": 10, + "_shadowOrthoSize": 5, + "_id": "a5kEe7MjFE5adVBACHFpTt" + }, + { + "__type__": "cc.StaticLightSettings", + "_baked": false, + "_editorOnly": false, + "_castShadow": false + }, + { + "__type__": "cc.Node", + "_name": "Main Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 6 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": -10, + "y": 10, + "z": 10 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.27781593346944056, + "y": -0.36497167621709875, + "z": -0.11507512748638377, + "w": 0.8811195706053617 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -35, + "y": -45, + "z": 0 + }, + "_id": "06Nqp2gjNCP7Z5SdzNL7wN" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 5 + }, + "_enabled": true, + "__prefab": null, + "_projection": 1, + "_priority": 0, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 10, + "_near": 1, + "_far": 1000, + "_color": { + "__type__": "cc.Color", + "r": 51, + "g": 51, + "b": 51, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 14, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 1822425087, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "d47F4V+UxM+JjfD4E7dhiV" + }, + { + "__type__": "cc.Node", + "_name": "Boot", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 8 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "0dUU28EJNPup5G+7A4CWJh" + }, + { + "__type__": "319b4InE5dKqIgu5pp05L99", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_id": "ac1kM1cQZH7LYD4ROliaVy" + }, + { + "__type__": "cc.SceneGlobals", + "ambient": { + "__id__": 10 + }, + "shadows": { + "__id__": 11 + }, + "_skybox": { + "__id__": 12 + }, + "fog": { + "__id__": 13 + }, + "octree": { + "__id__": 14 + }, + "skin": { + "__id__": 15 + }, + "lightProbeInfo": { + "__id__": 16 + }, + "postSettings": { + "__id__": 17 + }, + "bakedWithStationaryMainLight": false, + "bakedWithHighpLightmap": false + }, + { + "__type__": "cc.AmbientInfo", + "_skyColorHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyColor": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyIllumHDR": 20000, + "_skyIllum": 20000, + "_groundAlbedoHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_groundAlbedo": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_skyColorLDR": { + "__type__": "cc.Vec4", + "x": 0.452588, + "y": 0.607642, + "z": 0.755699, + "w": 0 + }, + "_skyIllumLDR": 0.8, + "_groundAlbedoLDR": { + "__type__": "cc.Vec4", + "x": 0.618555, + "y": 0.577848, + "z": 0.544564, + "w": 0 + } + }, + { + "__type__": "cc.ShadowsInfo", + "_enabled": false, + "_type": 0, + "_normal": { + "__type__": "cc.Vec3", + "x": 0, + "y": 1, + "z": 0 + }, + "_distance": 0, + "_planeBias": 1, + "_shadowColor": { + "__type__": "cc.Color", + "r": 76, + "g": 76, + "b": 76, + "a": 255 + }, + "_maxReceived": 4, + "_size": { + "__type__": "cc.Vec2", + "x": 1024, + "y": 1024 + } + }, + { + "__type__": "cc.SkyboxInfo", + "_envLightingType": 0, + "_envmapHDR": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmap": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmapLDR": { + "__uuid__": "6f01cf7f-81bf-4a7e-bd5d-0afc19696480@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_diffuseMapHDR": null, + "_diffuseMapLDR": null, + "_enabled": true, + "_useHDR": true, + "_editableMaterial": null, + "_reflectionHDR": null, + "_reflectionLDR": null, + "_rotationAngle": 0 + }, + { + "__type__": "cc.FogInfo", + "_type": 0, + "_fogColor": { + "__type__": "cc.Color", + "r": 200, + "g": 200, + "b": 200, + "a": 255 + }, + "_enabled": false, + "_fogDensity": 0.3, + "_fogStart": 0.5, + "_fogEnd": 300, + "_fogAtten": 5, + "_fogTop": 1.5, + "_fogRange": 1.2, + "_accurate": false + }, + { + "__type__": "cc.OctreeInfo", + "_enabled": false, + "_minPos": { + "__type__": "cc.Vec3", + "x": -1024, + "y": -1024, + "z": -1024 + }, + "_maxPos": { + "__type__": "cc.Vec3", + "x": 1024, + "y": 1024, + "z": 1024 + }, + "_depth": 8 + }, + { + "__type__": "cc.SkinInfo", + "_enabled": true, + "_blurRadius": 0.01, + "_sssIntensity": 3 + }, + { + "__type__": "cc.LightProbeInfo", + "_giScale": 1, + "_giSamples": 1024, + "_bounces": 2, + "_reduceRinging": 0, + "_showProbe": true, + "_showWireframe": true, + "_showConvex": false, + "_data": null, + "_lightProbeSphereVolume": 1 + }, + { + "__type__": "cc.PostSettingsInfo", + "_toneMappingType": 0 + } +] \ No newline at end of file diff --git a/assets/scenes/Boot.scene.meta b/assets/scenes/Boot.scene.meta new file mode 100644 index 0000000..5b21dd7 --- /dev/null +++ b/assets/scenes/Boot.scene.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.1.50", + "importer": "scene", + "imported": true, + "uuid": "2b3e945f-51bf-42cd-8a0e-74b1ddeeb94d", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/Boss_ShuangHuanFang.scene b/assets/scenes/Boss_ShuangHuanFang.scene new file mode 100644 index 0000000..df51de0 --- /dev/null +++ b/assets/scenes/Boss_ShuangHuanFang.scene @@ -0,0 +1,1325 @@ +[ + { + "__type__": "cc.SceneAsset", + "_name": "Boss_ShuangHuanFang", + "_objFlags": 0, + "__editorExtras__": {}, + "_native": "", + "scene": { + "__id__": 1 + } + }, + { + "__type__": "cc.Scene", + "_name": "Boss_ShuangHuanFang", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": null, + "_children": [ + { + "__id__": 2 + }, + { + "__id__": 5 + }, + { + "__id__": 7 + } + ], + "_active": true, + "_components": [], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "autoReleaseAssets": false, + "_globals": { + "__id__": 30 + }, + "_id": "d81fb1cf-6129-4e65-88b0-b2281dd15f92" + }, + { + "__type__": "cc.Node", + "_name": "Main Light", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 3 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.06397656665577071, + "y": -0.44608233363525845, + "z": -0.8239028751062036, + "w": -0.3436591377065261 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -117.894, + "y": -194.909, + "z": 38.562 + }, + "_id": "fbcZQn6j1NIKbXeU/oZFSN" + }, + { + "__type__": "cc.DirectionalLight", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 2 + }, + "_enabled": true, + "__prefab": null, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 250, + "b": 240, + "a": 255 + }, + "_useColorTemperature": false, + "_colorTemperature": 6550, + "_staticSettings": { + "__id__": 4 + }, + "_visibility": -325058561, + "_illuminanceHDR": 65000, + "_illuminance": 65000, + "_illuminanceLDR": 1.6927083333333335, + "_shadowEnabled": false, + "_shadowPcf": 0, + "_shadowBias": 0.00001, + "_shadowNormalBias": 0, + "_shadowSaturation": 1, + "_shadowDistance": 50, + "_shadowInvisibleOcclusionRange": 200, + "_csmLevel": 4, + "_csmLayerLambda": 0.75, + "_csmOptimizationMode": 2, + "_csmAdvancedOptions": false, + "_csmLayersTransition": false, + "_csmTransitionRange": 0.05, + "_shadowFixedArea": false, + "_shadowNear": 0.1, + "_shadowFar": 10, + "_shadowOrthoSize": 5, + "_id": "e4niThEMBHjL5IGJiMnrme" + }, + { + "__type__": "cc.StaticLightSettings", + "_baked": false, + "_editorOnly": false, + "_castShadow": false + }, + { + "__type__": "cc.Node", + "_name": "Main Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 6 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": -10, + "y": 10, + "z": 10 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.27781593346944056, + "y": -0.36497167621709875, + "z": -0.11507512748638377, + "w": 0.8811195706053617 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -35, + "y": -45, + "z": 0 + }, + "_id": "ecKS09T4JAGo0sNgA6Pl+P" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 5 + }, + "_enabled": true, + "__prefab": null, + "_projection": 1, + "_priority": 0, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 10, + "_near": 1, + "_far": 1000, + "_color": { + "__type__": "cc.Color", + "r": 51, + "g": 51, + "b": 51, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 14, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 1822425087, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "3bya4P4atIBbMebQrRG9TX" + }, + { + "__type__": "cc.Node", + "_name": "Canvas", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [ + { + "__id__": 8 + }, + { + "__id__": 10 + }, + { + "__id__": 18 + } + ], + "_active": true, + "_components": [ + { + "__id__": 26 + }, + { + "__id__": 27 + }, + { + "__id__": 28 + }, + { + "__id__": 29 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 480, + "y": 320, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "a0BcCA96ZA9ZPPjYSkH4N/" + }, + { + "__type__": "cc.Node", + "_name": "Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 9 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 1000 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "7c0A5MHudOib+RyTmqI6fa" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 8 + }, + "_enabled": true, + "__prefab": null, + "_projection": 0, + "_priority": 1073741824, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 320, + "_near": 1, + "_far": 2000, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 6, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 41943040, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "c6X1dDE2xKbJ9NKiT519ya" + }, + { + "__type__": "cc.Node", + "_name": "ButterFlyHit", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [ + { + "__id__": 11 + } + ], + "_active": true, + "_components": [ + { + "__id__": 14 + }, + { + "__id__": 15 + }, + { + "__id__": 16 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "bdcKJNCMhE7LUDbLUDhXLZ" + }, + { + "__type__": "cc.Node", + "_name": "Label", + "_objFlags": 512, + "__editorExtras__": {}, + "_parent": { + "__id__": 10 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 12 + }, + { + "__id__": 13 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "8c6W+NIAxGyLVMDcLLsJnN" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 11 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "01prPo2NBMqrdh4BNvgVFH" + }, + { + "__type__": "cc.Label", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 11 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_string": "ButterFlyHit", + "_horizontalAlign": 1, + "_verticalAlign": 1, + "_actualFontSize": 31.25, + "_fontSize": 20, + "_fontFamily": "Arial", + "_lineHeight": 40, + "_overflow": 1, + "_enableWrapText": false, + "_font": null, + "_isSystemFontUsed": true, + "_spacingX": 0, + "_isItalic": false, + "_isBold": false, + "_isUnderline": false, + "_underlineHeight": 2, + "_cacheMode": 0, + "_enableOutline": false, + "_outlineColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_outlineWidth": 2, + "_enableShadow": false, + "_shadowColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_shadowOffset": { + "__type__": "cc.Vec2", + "x": 2, + "y": 2 + }, + "_shadowBlur": 2, + "_id": "f1FHljqfhE3odWhbT/uhaP" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 10 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "a1EAPz7blKooFnDO3k8YMj" + }, + { + "__type__": "cc.Sprite", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 10 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_spriteFrame": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_type": 1, + "_fillType": 0, + "_sizeMode": 0, + "_fillCenter": { + "__type__": "cc.Vec2", + "x": 0, + "y": 0 + }, + "_fillStart": 0, + "_fillRange": 0, + "_isTrimmedMode": true, + "_useGrayscale": false, + "_atlas": null, + "_id": "94tKDfkStHdbQciFVCG6N4" + }, + { + "__type__": "cc.Button", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 10 + }, + "_enabled": true, + "__prefab": null, + "clickEvents": [ + { + "__id__": 17 + } + ], + "_interactable": true, + "_transition": 2, + "_normalColor": { + "__type__": "cc.Color", + "r": 214, + "g": 214, + "b": 214, + "a": 255 + }, + "_hoverColor": { + "__type__": "cc.Color", + "r": 211, + "g": 211, + "b": 211, + "a": 255 + }, + "_pressedColor": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_disabledColor": { + "__type__": "cc.Color", + "r": 124, + "g": 124, + "b": 124, + "a": 255 + }, + "_normalSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_hoverSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_pressedSprite": { + "__uuid__": "544e49d6-3f05-4fa8-9a9e-091f98fc2ce8@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_disabledSprite": { + "__uuid__": "951249e0-9f16-456d-8b85-a6ca954da16b@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_duration": 0.1, + "_zoomScale": 1.2, + "_target": { + "__id__": 10 + }, + "_id": "14KQPMt2lAp59Te8fR0Irh" + }, + { + "__type__": "cc.ClickEvent", + "target": { + "__id__": 7 + }, + "component": "", + "_componentId": "1ac58VvFH9BDIuLjeJnlStA", + "handler": "onButterflyHit", + "customEventData": "" + }, + { + "__type__": "cc.Node", + "_name": "BodyHit", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [ + { + "__id__": 19 + } + ], + "_active": true, + "_components": [ + { + "__id__": 22 + }, + { + "__id__": 23 + }, + { + "__id__": 24 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "69yo3jcbFEMa+eU8MvAOsb" + }, + { + "__type__": "cc.Node", + "_name": "Label", + "_objFlags": 512, + "__editorExtras__": {}, + "_parent": { + "__id__": 18 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 20 + }, + { + "__id__": 21 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "2d1KwATzVI6aRN7ntmxtda" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 19 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "2cuQGh4qNNpqlOBkCZuXuX" + }, + { + "__type__": "cc.Label", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 19 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_string": "BodyHit", + "_horizontalAlign": 1, + "_verticalAlign": 1, + "_actualFontSize": 31.25, + "_fontSize": 20, + "_fontFamily": "Arial", + "_lineHeight": 40, + "_overflow": 1, + "_enableWrapText": false, + "_font": null, + "_isSystemFontUsed": true, + "_spacingX": 0, + "_isItalic": false, + "_isBold": false, + "_isUnderline": false, + "_underlineHeight": 2, + "_cacheMode": 0, + "_enableOutline": false, + "_outlineColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_outlineWidth": 2, + "_enableShadow": false, + "_shadowColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_shadowOffset": { + "__type__": "cc.Vec2", + "x": 2, + "y": 2 + }, + "_shadowBlur": 2, + "_id": "39Iq8b3zFPqZBv1/L40AB8" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 18 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "52Kw6stphIZ5xABoaPSmMK" + }, + { + "__type__": "cc.Sprite", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 18 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_spriteFrame": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_type": 1, + "_fillType": 0, + "_sizeMode": 0, + "_fillCenter": { + "__type__": "cc.Vec2", + "x": 0, + "y": 0 + }, + "_fillStart": 0, + "_fillRange": 0, + "_isTrimmedMode": true, + "_useGrayscale": false, + "_atlas": null, + "_id": "72RU/e1RpBxKV8d8QzV756" + }, + { + "__type__": "cc.Button", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 18 + }, + "_enabled": true, + "__prefab": null, + "clickEvents": [ + { + "__id__": 25 + } + ], + "_interactable": true, + "_transition": 2, + "_normalColor": { + "__type__": "cc.Color", + "r": 214, + "g": 214, + "b": 214, + "a": 255 + }, + "_hoverColor": { + "__type__": "cc.Color", + "r": 211, + "g": 211, + "b": 211, + "a": 255 + }, + "_pressedColor": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_disabledColor": { + "__type__": "cc.Color", + "r": 124, + "g": 124, + "b": 124, + "a": 255 + }, + "_normalSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_hoverSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_pressedSprite": { + "__uuid__": "544e49d6-3f05-4fa8-9a9e-091f98fc2ce8@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_disabledSprite": { + "__uuid__": "951249e0-9f16-456d-8b85-a6ca954da16b@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_duration": 0.1, + "_zoomScale": 1.2, + "_target": { + "__id__": 18 + }, + "_id": "4eZoxxsSFCaplw/Jp2nET1" + }, + { + "__type__": "cc.ClickEvent", + "target": { + "__id__": 7 + }, + "component": "", + "_componentId": "1ac58VvFH9BDIuLjeJnlStA", + "handler": "onBodyHit", + "customEventData": "" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 960, + "height": 640 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "86rnjaqyhBappjeKRw2uGy" + }, + { + "__type__": "cc.Canvas", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_cameraComponent": { + "__id__": 9 + }, + "_alignCanvasWithScreen": true, + "_id": "0eO3ae6AFEGr8SQmtqg2G6" + }, + { + "__type__": "cc.Widget", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_alignFlags": 45, + "_target": null, + "_left": 0, + "_right": 0, + "_top": 0, + "_bottom": 0, + "_horizontalCenter": 0, + "_verticalCenter": 0, + "_isAbsLeft": true, + "_isAbsRight": true, + "_isAbsTop": true, + "_isAbsBottom": true, + "_isAbsHorizontalCenter": true, + "_isAbsVerticalCenter": true, + "_originalWidth": 0, + "_originalHeight": 0, + "_alignMode": 2, + "_lockFlags": 0, + "_id": "34H7jRWRxPSre//V+zEwzl" + }, + { + "__type__": "1ac58VvFH9BDIuLjeJnlStA", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "bossId": "shuang_huan_fang", + "_id": "d0mjRJk/JJJrbY/8zyxD9k" + }, + { + "__type__": "cc.SceneGlobals", + "ambient": { + "__id__": 31 + }, + "shadows": { + "__id__": 32 + }, + "_skybox": { + "__id__": 33 + }, + "fog": { + "__id__": 34 + }, + "octree": { + "__id__": 35 + }, + "skin": { + "__id__": 36 + }, + "lightProbeInfo": { + "__id__": 37 + }, + "postSettings": { + "__id__": 38 + }, + "bakedWithStationaryMainLight": false, + "bakedWithHighpLightmap": false + }, + { + "__type__": "cc.AmbientInfo", + "_skyColorHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyColor": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyIllumHDR": 20000, + "_skyIllum": 20000, + "_groundAlbedoHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_groundAlbedo": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_skyColorLDR": { + "__type__": "cc.Vec4", + "x": 0.452588, + "y": 0.607642, + "z": 0.755699, + "w": 0 + }, + "_skyIllumLDR": 0.8, + "_groundAlbedoLDR": { + "__type__": "cc.Vec4", + "x": 0.618555, + "y": 0.577848, + "z": 0.544564, + "w": 0 + } + }, + { + "__type__": "cc.ShadowsInfo", + "_enabled": false, + "_type": 0, + "_normal": { + "__type__": "cc.Vec3", + "x": 0, + "y": 1, + "z": 0 + }, + "_distance": 0, + "_planeBias": 1, + "_shadowColor": { + "__type__": "cc.Color", + "r": 76, + "g": 76, + "b": 76, + "a": 255 + }, + "_maxReceived": 4, + "_size": { + "__type__": "cc.Vec2", + "x": 1024, + "y": 1024 + } + }, + { + "__type__": "cc.SkyboxInfo", + "_envLightingType": 0, + "_envmapHDR": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmap": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmapLDR": { + "__uuid__": "6f01cf7f-81bf-4a7e-bd5d-0afc19696480@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_diffuseMapHDR": null, + "_diffuseMapLDR": null, + "_enabled": true, + "_useHDR": true, + "_editableMaterial": null, + "_reflectionHDR": null, + "_reflectionLDR": null, + "_rotationAngle": 0 + }, + { + "__type__": "cc.FogInfo", + "_type": 0, + "_fogColor": { + "__type__": "cc.Color", + "r": 200, + "g": 200, + "b": 200, + "a": 255 + }, + "_enabled": false, + "_fogDensity": 0.3, + "_fogStart": 0.5, + "_fogEnd": 300, + "_fogAtten": 5, + "_fogTop": 1.5, + "_fogRange": 1.2, + "_accurate": false + }, + { + "__type__": "cc.OctreeInfo", + "_enabled": false, + "_minPos": { + "__type__": "cc.Vec3", + "x": -1024, + "y": -1024, + "z": -1024 + }, + "_maxPos": { + "__type__": "cc.Vec3", + "x": 1024, + "y": 1024, + "z": 1024 + }, + "_depth": 8 + }, + { + "__type__": "cc.SkinInfo", + "_enabled": true, + "_blurRadius": 0.01, + "_sssIntensity": 3 + }, + { + "__type__": "cc.LightProbeInfo", + "_giScale": 1, + "_giSamples": 1024, + "_bounces": 2, + "_reduceRinging": 0, + "_showProbe": true, + "_showWireframe": true, + "_showConvex": false, + "_data": null, + "_lightProbeSphereVolume": 1 + }, + { + "__type__": "cc.PostSettingsInfo", + "_toneMappingType": 0 + } +] \ No newline at end of file diff --git a/assets/scenes/Boss_ShuangHuanFang.scene.meta b/assets/scenes/Boss_ShuangHuanFang.scene.meta new file mode 100644 index 0000000..50c49f0 --- /dev/null +++ b/assets/scenes/Boss_ShuangHuanFang.scene.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.1.50", + "importer": "scene", + "imported": true, + "uuid": "d81fb1cf-6129-4e65-88b0-b2281dd15f92", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/Level_1_1.scene b/assets/scenes/Level_1_1.scene new file mode 100644 index 0000000..1a6f66c --- /dev/null +++ b/assets/scenes/Level_1_1.scene @@ -0,0 +1,668 @@ +[ + { + "__type__": "cc.SceneAsset", + "_name": "Level_1_1", + "_objFlags": 0, + "__editorExtras__": {}, + "_native": "", + "scene": { + "__id__": 1 + } + }, + { + "__type__": "cc.Scene", + "_name": "Level_1_1", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": null, + "_children": [ + { + "__id__": 2 + }, + { + "__id__": 5 + }, + { + "__id__": 7 + } + ], + "_active": true, + "_components": [], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "autoReleaseAssets": false, + "_globals": { + "__id__": 14 + }, + "_id": "42f02e7d-5bb5-47e6-8596-338fff384670" + }, + { + "__type__": "cc.Node", + "_name": "Main Light", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 3 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.06397656665577071, + "y": -0.44608233363525845, + "z": -0.8239028751062036, + "w": -0.3436591377065261 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -117.894, + "y": -194.909, + "z": 38.562 + }, + "_id": "95nWfFP/ZHVrS8tAdw/3F1" + }, + { + "__type__": "cc.DirectionalLight", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 2 + }, + "_enabled": true, + "__prefab": null, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 250, + "b": 240, + "a": 255 + }, + "_useColorTemperature": false, + "_colorTemperature": 6550, + "_staticSettings": { + "__id__": 4 + }, + "_visibility": -325058561, + "_illuminanceHDR": 65000, + "_illuminance": 65000, + "_illuminanceLDR": 1.6927083333333335, + "_shadowEnabled": false, + "_shadowPcf": 0, + "_shadowBias": 0.00001, + "_shadowNormalBias": 0, + "_shadowSaturation": 1, + "_shadowDistance": 50, + "_shadowInvisibleOcclusionRange": 200, + "_csmLevel": 4, + "_csmLayerLambda": 0.75, + "_csmOptimizationMode": 2, + "_csmAdvancedOptions": false, + "_csmLayersTransition": false, + "_csmTransitionRange": 0.05, + "_shadowFixedArea": false, + "_shadowNear": 0.1, + "_shadowFar": 10, + "_shadowOrthoSize": 5, + "_id": "32wPphBG9C0KNwI4JthOoR" + }, + { + "__type__": "cc.StaticLightSettings", + "_baked": false, + "_editorOnly": false, + "_castShadow": false + }, + { + "__type__": "cc.Node", + "_name": "Main Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 6 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": -10, + "y": 10, + "z": 10 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.27781593346944056, + "y": -0.36497167621709875, + "z": -0.11507512748638377, + "w": 0.8811195706053617 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -35, + "y": -45, + "z": 0 + }, + "_id": "b4APjNQ8RGo7/FlcnSI+7Q" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 5 + }, + "_enabled": true, + "__prefab": null, + "_projection": 1, + "_priority": 0, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 10, + "_near": 1, + "_far": 1000, + "_color": { + "__type__": "cc.Color", + "r": 51, + "g": 51, + "b": 51, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 14, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 1822425087, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "0f8bETd/VGBb4JQJtPPnGe" + }, + { + "__type__": "cc.Node", + "_name": "Canvas", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [ + { + "__id__": 8 + } + ], + "_active": true, + "_components": [ + { + "__id__": 10 + }, + { + "__id__": 11 + }, + { + "__id__": 12 + }, + { + "__id__": 13 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "65q1+BrAdHZK48I2z/tyfl" + }, + { + "__type__": "cc.Node", + "_name": "Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 9 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 1000 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "d1hxpVHBVAGpAcR+Vd1JmB" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 8 + }, + "_enabled": true, + "__prefab": null, + "_projection": 0, + "_priority": 1073741824, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 270, + "_near": 1, + "_far": 2000, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 6, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 41943040, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "f7WJ/wztRJ9bCfTdaecCIo" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 960, + "height": 540 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "68jzHbUNFB7YyZULGxf5kX" + }, + { + "__type__": "cc.Canvas", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_cameraComponent": { + "__id__": 9 + }, + "_alignCanvasWithScreen": true, + "_id": "519M0WB19Fh6mcZ7zPThnl" + }, + { + "__type__": "cc.Widget", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_alignFlags": 45, + "_target": null, + "_left": 0, + "_right": 0, + "_top": 0, + "_bottom": 0, + "_horizontalCenter": 0, + "_verticalCenter": 0, + "_isAbsLeft": true, + "_isAbsRight": true, + "_isAbsTop": true, + "_isAbsBottom": true, + "_isAbsHorizontalCenter": true, + "_isAbsVerticalCenter": true, + "_originalWidth": 0, + "_originalHeight": 0, + "_alignMode": 2, + "_lockFlags": 0, + "_id": "0bMD0AklFN1bcxBgLk4HlK" + }, + { + "__type__": "621cc+aaUhGP54Gu7lAJ6Np", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "levelId": "1-1", + "nextSceneName": "", + "_id": "3eYljZWB5CBpVh3IE56nW3" + }, + { + "__type__": "cc.SceneGlobals", + "ambient": { + "__id__": 15 + }, + "shadows": { + "__id__": 16 + }, + "_skybox": { + "__id__": 17 + }, + "fog": { + "__id__": 18 + }, + "octree": { + "__id__": 19 + }, + "skin": { + "__id__": 20 + }, + "lightProbeInfo": { + "__id__": 21 + }, + "postSettings": { + "__id__": 22 + }, + "bakedWithStationaryMainLight": false, + "bakedWithHighpLightmap": false + }, + { + "__type__": "cc.AmbientInfo", + "_skyColorHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyColor": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyIllumHDR": 20000, + "_skyIllum": 20000, + "_groundAlbedoHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_groundAlbedo": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_skyColorLDR": { + "__type__": "cc.Vec4", + "x": 0.452588, + "y": 0.607642, + "z": 0.755699, + "w": 0 + }, + "_skyIllumLDR": 0.8, + "_groundAlbedoLDR": { + "__type__": "cc.Vec4", + "x": 0.618555, + "y": 0.577848, + "z": 0.544564, + "w": 0 + } + }, + { + "__type__": "cc.ShadowsInfo", + "_enabled": false, + "_type": 0, + "_normal": { + "__type__": "cc.Vec3", + "x": 0, + "y": 1, + "z": 0 + }, + "_distance": 0, + "_planeBias": 1, + "_shadowColor": { + "__type__": "cc.Color", + "r": 76, + "g": 76, + "b": 76, + "a": 255 + }, + "_maxReceived": 4, + "_size": { + "__type__": "cc.Vec2", + "x": 1024, + "y": 1024 + } + }, + { + "__type__": "cc.SkyboxInfo", + "_envLightingType": 0, + "_envmapHDR": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmap": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmapLDR": { + "__uuid__": "6f01cf7f-81bf-4a7e-bd5d-0afc19696480@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_diffuseMapHDR": null, + "_diffuseMapLDR": null, + "_enabled": true, + "_useHDR": true, + "_editableMaterial": null, + "_reflectionHDR": null, + "_reflectionLDR": null, + "_rotationAngle": 0 + }, + { + "__type__": "cc.FogInfo", + "_type": 0, + "_fogColor": { + "__type__": "cc.Color", + "r": 200, + "g": 200, + "b": 200, + "a": 255 + }, + "_enabled": false, + "_fogDensity": 0.3, + "_fogStart": 0.5, + "_fogEnd": 300, + "_fogAtten": 5, + "_fogTop": 1.5, + "_fogRange": 1.2, + "_accurate": false + }, + { + "__type__": "cc.OctreeInfo", + "_enabled": false, + "_minPos": { + "__type__": "cc.Vec3", + "x": -1024, + "y": -1024, + "z": -1024 + }, + "_maxPos": { + "__type__": "cc.Vec3", + "x": 1024, + "y": 1024, + "z": 1024 + }, + "_depth": 8 + }, + { + "__type__": "cc.SkinInfo", + "_enabled": true, + "_blurRadius": 0.01, + "_sssIntensity": 3 + }, + { + "__type__": "cc.LightProbeInfo", + "_giScale": 1, + "_giSamples": 1024, + "_bounces": 2, + "_reduceRinging": 0, + "_showProbe": true, + "_showWireframe": true, + "_showConvex": false, + "_data": null, + "_lightProbeSphereVolume": 1 + }, + { + "__type__": "cc.PostSettingsInfo", + "_toneMappingType": 0 + } +] \ No newline at end of file diff --git a/assets/scenes/Level_1_1.scene.meta b/assets/scenes/Level_1_1.scene.meta new file mode 100644 index 0000000..144fe20 --- /dev/null +++ b/assets/scenes/Level_1_1.scene.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.1.50", + "importer": "scene", + "imported": true, + "uuid": "42f02e7d-5bb5-47e6-8596-338fff384670", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/Level_1_2.scene b/assets/scenes/Level_1_2.scene new file mode 100644 index 0000000..4486a3c --- /dev/null +++ b/assets/scenes/Level_1_2.scene @@ -0,0 +1,668 @@ +[ + { + "__type__": "cc.SceneAsset", + "_name": "Level_1_2", + "_objFlags": 0, + "__editorExtras__": {}, + "_native": "", + "scene": { + "__id__": 1 + } + }, + { + "__type__": "cc.Scene", + "_name": "Level_1_2", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": null, + "_children": [ + { + "__id__": 2 + }, + { + "__id__": 5 + }, + { + "__id__": 7 + } + ], + "_active": true, + "_components": [], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "autoReleaseAssets": false, + "_globals": { + "__id__": 14 + }, + "_id": "44f4ec8d-9e86-4bc9-8ac0-f9a5969fd904" + }, + { + "__type__": "cc.Node", + "_name": "Main Light", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 3 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.06397656665577071, + "y": -0.44608233363525845, + "z": -0.8239028751062036, + "w": -0.3436591377065261 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -117.894, + "y": -194.909, + "z": 38.562 + }, + "_id": "32tyhZP6BLC6TpFnGLFzeL" + }, + { + "__type__": "cc.DirectionalLight", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 2 + }, + "_enabled": true, + "__prefab": null, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 250, + "b": 240, + "a": 255 + }, + "_useColorTemperature": false, + "_colorTemperature": 6550, + "_staticSettings": { + "__id__": 4 + }, + "_visibility": -325058561, + "_illuminanceHDR": 65000, + "_illuminance": 65000, + "_illuminanceLDR": 1.6927083333333335, + "_shadowEnabled": false, + "_shadowPcf": 0, + "_shadowBias": 0.00001, + "_shadowNormalBias": 0, + "_shadowSaturation": 1, + "_shadowDistance": 50, + "_shadowInvisibleOcclusionRange": 200, + "_csmLevel": 4, + "_csmLayerLambda": 0.75, + "_csmOptimizationMode": 2, + "_csmAdvancedOptions": false, + "_csmLayersTransition": false, + "_csmTransitionRange": 0.05, + "_shadowFixedArea": false, + "_shadowNear": 0.1, + "_shadowFar": 10, + "_shadowOrthoSize": 5, + "_id": "c0J9nv9FpDaa2bKgaaOI1r" + }, + { + "__type__": "cc.StaticLightSettings", + "_baked": false, + "_editorOnly": false, + "_castShadow": false + }, + { + "__type__": "cc.Node", + "_name": "Main Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 6 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": -10, + "y": 10, + "z": 10 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.27781593346944056, + "y": -0.36497167621709875, + "z": -0.11507512748638377, + "w": 0.8811195706053617 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -35, + "y": -45, + "z": 0 + }, + "_id": "5d27dpcoxB9akbvEWqi8vz" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 5 + }, + "_enabled": true, + "__prefab": null, + "_projection": 1, + "_priority": 0, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 10, + "_near": 1, + "_far": 1000, + "_color": { + "__type__": "cc.Color", + "r": 51, + "g": 51, + "b": 51, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 14, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 1822425087, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "029HFRWN1J8YvFQsV7Gu8A" + }, + { + "__type__": "cc.Node", + "_name": "Canvas", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [ + { + "__id__": 8 + } + ], + "_active": true, + "_components": [ + { + "__id__": 10 + }, + { + "__id__": 11 + }, + { + "__id__": 12 + }, + { + "__id__": 13 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 480, + "y": 320, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "54ZX6tiyFMc4SDiWJ5vxKH" + }, + { + "__type__": "cc.Node", + "_name": "Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 9 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 1000 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "3d5sXJORdCY6bThhLcQOkq" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 8 + }, + "_enabled": true, + "__prefab": null, + "_projection": 0, + "_priority": 1073741824, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 320, + "_near": 1, + "_far": 2000, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 6, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 41943040, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "d9mj7CqPpGsaLClAAk7Kke" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 960, + "height": 640 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "4e2AaSUPZLU5V20FEmbbH8" + }, + { + "__type__": "cc.Canvas", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_cameraComponent": { + "__id__": 9 + }, + "_alignCanvasWithScreen": true, + "_id": "41OPg1n5hH4ZLQukxb+Kly" + }, + { + "__type__": "cc.Widget", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_alignFlags": 45, + "_target": null, + "_left": 0, + "_right": 0, + "_top": 0, + "_bottom": 0, + "_horizontalCenter": 0, + "_verticalCenter": 0, + "_isAbsLeft": true, + "_isAbsRight": true, + "_isAbsTop": true, + "_isAbsBottom": true, + "_isAbsHorizontalCenter": true, + "_isAbsVerticalCenter": true, + "_originalWidth": 0, + "_originalHeight": 0, + "_alignMode": 2, + "_lockFlags": 0, + "_id": "d076v3n+RKO4WAvtTbLcFz" + }, + { + "__type__": "621cc+aaUhGP54Gu7lAJ6Np", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "levelId": "1-2", + "nextSceneName": "", + "_id": "62vVvss9RAGJgEfGEJMIVb" + }, + { + "__type__": "cc.SceneGlobals", + "ambient": { + "__id__": 15 + }, + "shadows": { + "__id__": 16 + }, + "_skybox": { + "__id__": 17 + }, + "fog": { + "__id__": 18 + }, + "octree": { + "__id__": 19 + }, + "skin": { + "__id__": 20 + }, + "lightProbeInfo": { + "__id__": 21 + }, + "postSettings": { + "__id__": 22 + }, + "bakedWithStationaryMainLight": false, + "bakedWithHighpLightmap": false + }, + { + "__type__": "cc.AmbientInfo", + "_skyColorHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyColor": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyIllumHDR": 20000, + "_skyIllum": 20000, + "_groundAlbedoHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_groundAlbedo": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_skyColorLDR": { + "__type__": "cc.Vec4", + "x": 0.452588, + "y": 0.607642, + "z": 0.755699, + "w": 0 + }, + "_skyIllumLDR": 0.8, + "_groundAlbedoLDR": { + "__type__": "cc.Vec4", + "x": 0.618555, + "y": 0.577848, + "z": 0.544564, + "w": 0 + } + }, + { + "__type__": "cc.ShadowsInfo", + "_enabled": false, + "_type": 0, + "_normal": { + "__type__": "cc.Vec3", + "x": 0, + "y": 1, + "z": 0 + }, + "_distance": 0, + "_planeBias": 1, + "_shadowColor": { + "__type__": "cc.Color", + "r": 76, + "g": 76, + "b": 76, + "a": 255 + }, + "_maxReceived": 4, + "_size": { + "__type__": "cc.Vec2", + "x": 1024, + "y": 1024 + } + }, + { + "__type__": "cc.SkyboxInfo", + "_envLightingType": 0, + "_envmapHDR": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmap": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmapLDR": { + "__uuid__": "6f01cf7f-81bf-4a7e-bd5d-0afc19696480@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_diffuseMapHDR": null, + "_diffuseMapLDR": null, + "_enabled": true, + "_useHDR": true, + "_editableMaterial": null, + "_reflectionHDR": null, + "_reflectionLDR": null, + "_rotationAngle": 0 + }, + { + "__type__": "cc.FogInfo", + "_type": 0, + "_fogColor": { + "__type__": "cc.Color", + "r": 200, + "g": 200, + "b": 200, + "a": 255 + }, + "_enabled": false, + "_fogDensity": 0.3, + "_fogStart": 0.5, + "_fogEnd": 300, + "_fogAtten": 5, + "_fogTop": 1.5, + "_fogRange": 1.2, + "_accurate": false + }, + { + "__type__": "cc.OctreeInfo", + "_enabled": false, + "_minPos": { + "__type__": "cc.Vec3", + "x": -1024, + "y": -1024, + "z": -1024 + }, + "_maxPos": { + "__type__": "cc.Vec3", + "x": 1024, + "y": 1024, + "z": 1024 + }, + "_depth": 8 + }, + { + "__type__": "cc.SkinInfo", + "_enabled": true, + "_blurRadius": 0.01, + "_sssIntensity": 3 + }, + { + "__type__": "cc.LightProbeInfo", + "_giScale": 1, + "_giSamples": 1024, + "_bounces": 2, + "_reduceRinging": 0, + "_showProbe": true, + "_showWireframe": true, + "_showConvex": false, + "_data": null, + "_lightProbeSphereVolume": 1 + }, + { + "__type__": "cc.PostSettingsInfo", + "_toneMappingType": 0 + } +] \ No newline at end of file diff --git a/assets/scenes/Level_1_2.scene.meta b/assets/scenes/Level_1_2.scene.meta new file mode 100644 index 0000000..e4669a3 --- /dev/null +++ b/assets/scenes/Level_1_2.scene.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.1.50", + "importer": "scene", + "imported": true, + "uuid": "44f4ec8d-9e86-4bc9-8ac0-f9a5969fd904", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/Level_1_3.scene b/assets/scenes/Level_1_3.scene new file mode 100644 index 0000000..0da2fb3 --- /dev/null +++ b/assets/scenes/Level_1_3.scene @@ -0,0 +1,668 @@ +[ + { + "__type__": "cc.SceneAsset", + "_name": "Level_1_3", + "_objFlags": 0, + "__editorExtras__": {}, + "_native": "", + "scene": { + "__id__": 1 + } + }, + { + "__type__": "cc.Scene", + "_name": "Level_1_3", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": null, + "_children": [ + { + "__id__": 2 + }, + { + "__id__": 5 + }, + { + "__id__": 7 + } + ], + "_active": true, + "_components": [], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "autoReleaseAssets": false, + "_globals": { + "__id__": 14 + }, + "_id": "c21438e7-b7ac-4763-8997-5171521aa922" + }, + { + "__type__": "cc.Node", + "_name": "Main Light", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 3 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.06397656665577071, + "y": -0.44608233363525845, + "z": -0.8239028751062036, + "w": -0.3436591377065261 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -117.894, + "y": -194.909, + "z": 38.562 + }, + "_id": "daKDdrq/tKi77ph6pUlrnG" + }, + { + "__type__": "cc.DirectionalLight", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 2 + }, + "_enabled": true, + "__prefab": null, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 250, + "b": 240, + "a": 255 + }, + "_useColorTemperature": false, + "_colorTemperature": 6550, + "_staticSettings": { + "__id__": 4 + }, + "_visibility": -325058561, + "_illuminanceHDR": 65000, + "_illuminance": 65000, + "_illuminanceLDR": 1.6927083333333335, + "_shadowEnabled": false, + "_shadowPcf": 0, + "_shadowBias": 0.00001, + "_shadowNormalBias": 0, + "_shadowSaturation": 1, + "_shadowDistance": 50, + "_shadowInvisibleOcclusionRange": 200, + "_csmLevel": 4, + "_csmLayerLambda": 0.75, + "_csmOptimizationMode": 2, + "_csmAdvancedOptions": false, + "_csmLayersTransition": false, + "_csmTransitionRange": 0.05, + "_shadowFixedArea": false, + "_shadowNear": 0.1, + "_shadowFar": 10, + "_shadowOrthoSize": 5, + "_id": "63do9z4bhMHo3XjJwkMvbs" + }, + { + "__type__": "cc.StaticLightSettings", + "_baked": false, + "_editorOnly": false, + "_castShadow": false + }, + { + "__type__": "cc.Node", + "_name": "Main Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 6 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": -10, + "y": 10, + "z": 10 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.27781593346944056, + "y": -0.36497167621709875, + "z": -0.11507512748638377, + "w": 0.8811195706053617 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -35, + "y": -45, + "z": 0 + }, + "_id": "d63nGGSilEQIGDemx+pmei" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 5 + }, + "_enabled": true, + "__prefab": null, + "_projection": 1, + "_priority": 0, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 10, + "_near": 1, + "_far": 1000, + "_color": { + "__type__": "cc.Color", + "r": 51, + "g": 51, + "b": 51, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 14, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 1822425087, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "bcJ8YpFxJE1KMYus/70ZQC" + }, + { + "__type__": "cc.Node", + "_name": "Canvas", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [ + { + "__id__": 8 + } + ], + "_active": true, + "_components": [ + { + "__id__": 10 + }, + { + "__id__": 11 + }, + { + "__id__": 12 + }, + { + "__id__": 13 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 480, + "y": 320, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "fbeaI3kOlFgrW0K98Hu+K2" + }, + { + "__type__": "cc.Node", + "_name": "Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 9 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 1000 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "f6biv7EqVGb68UCgFgi7AI" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 8 + }, + "_enabled": true, + "__prefab": null, + "_projection": 0, + "_priority": 1073741824, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 320, + "_near": 1, + "_far": 2000, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 6, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 41943040, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "85RvKoaBxNlbqfL4sdUQTG" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 960, + "height": 640 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "3cMPe1SvNDxYkaqZ0oC+mh" + }, + { + "__type__": "cc.Canvas", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_cameraComponent": { + "__id__": 9 + }, + "_alignCanvasWithScreen": true, + "_id": "4atGt+nWlD94rE0/FXd0Bu" + }, + { + "__type__": "cc.Widget", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_alignFlags": 45, + "_target": null, + "_left": 0, + "_right": 0, + "_top": 0, + "_bottom": 0, + "_horizontalCenter": 0, + "_verticalCenter": 0, + "_isAbsLeft": true, + "_isAbsRight": true, + "_isAbsTop": true, + "_isAbsBottom": true, + "_isAbsHorizontalCenter": true, + "_isAbsVerticalCenter": true, + "_originalWidth": 0, + "_originalHeight": 0, + "_alignMode": 2, + "_lockFlags": 0, + "_id": "edCrVYRgdJn4NBmKypz83F" + }, + { + "__type__": "621cc+aaUhGP54Gu7lAJ6Np", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "levelId": "1-3", + "nextSceneName": "", + "_id": "7bEXxHJ5VJT7uvhF+VQTUz" + }, + { + "__type__": "cc.SceneGlobals", + "ambient": { + "__id__": 15 + }, + "shadows": { + "__id__": 16 + }, + "_skybox": { + "__id__": 17 + }, + "fog": { + "__id__": 18 + }, + "octree": { + "__id__": 19 + }, + "skin": { + "__id__": 20 + }, + "lightProbeInfo": { + "__id__": 21 + }, + "postSettings": { + "__id__": 22 + }, + "bakedWithStationaryMainLight": false, + "bakedWithHighpLightmap": false + }, + { + "__type__": "cc.AmbientInfo", + "_skyColorHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyColor": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyIllumHDR": 20000, + "_skyIllum": 20000, + "_groundAlbedoHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_groundAlbedo": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_skyColorLDR": { + "__type__": "cc.Vec4", + "x": 0.452588, + "y": 0.607642, + "z": 0.755699, + "w": 0 + }, + "_skyIllumLDR": 0.8, + "_groundAlbedoLDR": { + "__type__": "cc.Vec4", + "x": 0.618555, + "y": 0.577848, + "z": 0.544564, + "w": 0 + } + }, + { + "__type__": "cc.ShadowsInfo", + "_enabled": false, + "_type": 0, + "_normal": { + "__type__": "cc.Vec3", + "x": 0, + "y": 1, + "z": 0 + }, + "_distance": 0, + "_planeBias": 1, + "_shadowColor": { + "__type__": "cc.Color", + "r": 76, + "g": 76, + "b": 76, + "a": 255 + }, + "_maxReceived": 4, + "_size": { + "__type__": "cc.Vec2", + "x": 1024, + "y": 1024 + } + }, + { + "__type__": "cc.SkyboxInfo", + "_envLightingType": 0, + "_envmapHDR": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmap": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmapLDR": { + "__uuid__": "6f01cf7f-81bf-4a7e-bd5d-0afc19696480@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_diffuseMapHDR": null, + "_diffuseMapLDR": null, + "_enabled": true, + "_useHDR": true, + "_editableMaterial": null, + "_reflectionHDR": null, + "_reflectionLDR": null, + "_rotationAngle": 0 + }, + { + "__type__": "cc.FogInfo", + "_type": 0, + "_fogColor": { + "__type__": "cc.Color", + "r": 200, + "g": 200, + "b": 200, + "a": 255 + }, + "_enabled": false, + "_fogDensity": 0.3, + "_fogStart": 0.5, + "_fogEnd": 300, + "_fogAtten": 5, + "_fogTop": 1.5, + "_fogRange": 1.2, + "_accurate": false + }, + { + "__type__": "cc.OctreeInfo", + "_enabled": false, + "_minPos": { + "__type__": "cc.Vec3", + "x": -1024, + "y": -1024, + "z": -1024 + }, + "_maxPos": { + "__type__": "cc.Vec3", + "x": 1024, + "y": 1024, + "z": 1024 + }, + "_depth": 8 + }, + { + "__type__": "cc.SkinInfo", + "_enabled": true, + "_blurRadius": 0.01, + "_sssIntensity": 3 + }, + { + "__type__": "cc.LightProbeInfo", + "_giScale": 1, + "_giSamples": 1024, + "_bounces": 2, + "_reduceRinging": 0, + "_showProbe": true, + "_showWireframe": true, + "_showConvex": false, + "_data": null, + "_lightProbeSphereVolume": 1 + }, + { + "__type__": "cc.PostSettingsInfo", + "_toneMappingType": 0 + } +] \ No newline at end of file diff --git a/assets/scenes/Level_1_3.scene.meta b/assets/scenes/Level_1_3.scene.meta new file mode 100644 index 0000000..14c20a7 --- /dev/null +++ b/assets/scenes/Level_1_3.scene.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.1.50", + "importer": "scene", + "imported": true, + "uuid": "c21438e7-b7ac-4763-8997-5171521aa922", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/Level_1_4.scene b/assets/scenes/Level_1_4.scene new file mode 100644 index 0000000..8662635 --- /dev/null +++ b/assets/scenes/Level_1_4.scene @@ -0,0 +1,668 @@ +[ + { + "__type__": "cc.SceneAsset", + "_name": "Level_1_4", + "_objFlags": 0, + "__editorExtras__": {}, + "_native": "", + "scene": { + "__id__": 1 + } + }, + { + "__type__": "cc.Scene", + "_name": "Level_1_4", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": null, + "_children": [ + { + "__id__": 2 + }, + { + "__id__": 5 + }, + { + "__id__": 7 + } + ], + "_active": true, + "_components": [], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "autoReleaseAssets": false, + "_globals": { + "__id__": 14 + }, + "_id": "a69a7891-0d48-414b-80f3-379d7d6fb0e1" + }, + { + "__type__": "cc.Node", + "_name": "Main Light", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 3 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.06397656665577071, + "y": -0.44608233363525845, + "z": -0.8239028751062036, + "w": -0.3436591377065261 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -117.894, + "y": -194.909, + "z": 38.562 + }, + "_id": "95pdSxAvRO7Zka7vFhroYj" + }, + { + "__type__": "cc.DirectionalLight", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 2 + }, + "_enabled": true, + "__prefab": null, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 250, + "b": 240, + "a": 255 + }, + "_useColorTemperature": false, + "_colorTemperature": 6550, + "_staticSettings": { + "__id__": 4 + }, + "_visibility": -325058561, + "_illuminanceHDR": 65000, + "_illuminance": 65000, + "_illuminanceLDR": 1.6927083333333335, + "_shadowEnabled": false, + "_shadowPcf": 0, + "_shadowBias": 0.00001, + "_shadowNormalBias": 0, + "_shadowSaturation": 1, + "_shadowDistance": 50, + "_shadowInvisibleOcclusionRange": 200, + "_csmLevel": 4, + "_csmLayerLambda": 0.75, + "_csmOptimizationMode": 2, + "_csmAdvancedOptions": false, + "_csmLayersTransition": false, + "_csmTransitionRange": 0.05, + "_shadowFixedArea": false, + "_shadowNear": 0.1, + "_shadowFar": 10, + "_shadowOrthoSize": 5, + "_id": "4cPFUHGt9Oua7TRyLi1tVh" + }, + { + "__type__": "cc.StaticLightSettings", + "_baked": false, + "_editorOnly": false, + "_castShadow": false + }, + { + "__type__": "cc.Node", + "_name": "Main Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 6 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": -10, + "y": 10, + "z": 10 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.27781593346944056, + "y": -0.36497167621709875, + "z": -0.11507512748638377, + "w": 0.8811195706053617 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -35, + "y": -45, + "z": 0 + }, + "_id": "9dNOghtfxOo7Ub6H/NUq0l" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 5 + }, + "_enabled": true, + "__prefab": null, + "_projection": 1, + "_priority": 0, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 10, + "_near": 1, + "_far": 1000, + "_color": { + "__type__": "cc.Color", + "r": 51, + "g": 51, + "b": 51, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 14, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 1822425087, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "dak3Nx7FVAtpPvGXVyKJzz" + }, + { + "__type__": "cc.Node", + "_name": "Canvas", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [ + { + "__id__": 8 + } + ], + "_active": true, + "_components": [ + { + "__id__": 10 + }, + { + "__id__": 11 + }, + { + "__id__": 12 + }, + { + "__id__": 13 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 480, + "y": 320, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "43a+eKNcNMz4jWft/3lPOa" + }, + { + "__type__": "cc.Node", + "_name": "Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 9 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 1000 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "c0J/IiRYdKGoYfqLOwe2oh" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 8 + }, + "_enabled": true, + "__prefab": null, + "_projection": 0, + "_priority": 1073741824, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 320, + "_near": 1, + "_far": 2000, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 6, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 41943040, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "4e1X3AFXZAQqNHhhBdeAu1" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 960, + "height": 640 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "baBKTWnedNZokWGZAPAoL6" + }, + { + "__type__": "cc.Canvas", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_cameraComponent": { + "__id__": 9 + }, + "_alignCanvasWithScreen": true, + "_id": "7a1FIGB95Gn4Lgiy/JU1VF" + }, + { + "__type__": "cc.Widget", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_alignFlags": 45, + "_target": null, + "_left": 0, + "_right": 0, + "_top": 0, + "_bottom": 0, + "_horizontalCenter": 0, + "_verticalCenter": 0, + "_isAbsLeft": true, + "_isAbsRight": true, + "_isAbsTop": true, + "_isAbsBottom": true, + "_isAbsHorizontalCenter": true, + "_isAbsVerticalCenter": true, + "_originalWidth": 0, + "_originalHeight": 0, + "_alignMode": 2, + "_lockFlags": 0, + "_id": "86PXn4RSxFYaOTdyrFtbXh" + }, + { + "__type__": "621cc+aaUhGP54Gu7lAJ6Np", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "levelId": "1-4", + "nextSceneName": "", + "_id": "c8gYjUIdVLH6YW0+6l1vQm" + }, + { + "__type__": "cc.SceneGlobals", + "ambient": { + "__id__": 15 + }, + "shadows": { + "__id__": 16 + }, + "_skybox": { + "__id__": 17 + }, + "fog": { + "__id__": 18 + }, + "octree": { + "__id__": 19 + }, + "skin": { + "__id__": 20 + }, + "lightProbeInfo": { + "__id__": 21 + }, + "postSettings": { + "__id__": 22 + }, + "bakedWithStationaryMainLight": false, + "bakedWithHighpLightmap": false + }, + { + "__type__": "cc.AmbientInfo", + "_skyColorHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyColor": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyIllumHDR": 20000, + "_skyIllum": 20000, + "_groundAlbedoHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_groundAlbedo": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_skyColorLDR": { + "__type__": "cc.Vec4", + "x": 0.452588, + "y": 0.607642, + "z": 0.755699, + "w": 0 + }, + "_skyIllumLDR": 0.8, + "_groundAlbedoLDR": { + "__type__": "cc.Vec4", + "x": 0.618555, + "y": 0.577848, + "z": 0.544564, + "w": 0 + } + }, + { + "__type__": "cc.ShadowsInfo", + "_enabled": false, + "_type": 0, + "_normal": { + "__type__": "cc.Vec3", + "x": 0, + "y": 1, + "z": 0 + }, + "_distance": 0, + "_planeBias": 1, + "_shadowColor": { + "__type__": "cc.Color", + "r": 76, + "g": 76, + "b": 76, + "a": 255 + }, + "_maxReceived": 4, + "_size": { + "__type__": "cc.Vec2", + "x": 1024, + "y": 1024 + } + }, + { + "__type__": "cc.SkyboxInfo", + "_envLightingType": 0, + "_envmapHDR": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmap": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmapLDR": { + "__uuid__": "6f01cf7f-81bf-4a7e-bd5d-0afc19696480@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_diffuseMapHDR": null, + "_diffuseMapLDR": null, + "_enabled": true, + "_useHDR": true, + "_editableMaterial": null, + "_reflectionHDR": null, + "_reflectionLDR": null, + "_rotationAngle": 0 + }, + { + "__type__": "cc.FogInfo", + "_type": 0, + "_fogColor": { + "__type__": "cc.Color", + "r": 200, + "g": 200, + "b": 200, + "a": 255 + }, + "_enabled": false, + "_fogDensity": 0.3, + "_fogStart": 0.5, + "_fogEnd": 300, + "_fogAtten": 5, + "_fogTop": 1.5, + "_fogRange": 1.2, + "_accurate": false + }, + { + "__type__": "cc.OctreeInfo", + "_enabled": false, + "_minPos": { + "__type__": "cc.Vec3", + "x": -1024, + "y": -1024, + "z": -1024 + }, + "_maxPos": { + "__type__": "cc.Vec3", + "x": 1024, + "y": 1024, + "z": 1024 + }, + "_depth": 8 + }, + { + "__type__": "cc.SkinInfo", + "_enabled": true, + "_blurRadius": 0.01, + "_sssIntensity": 3 + }, + { + "__type__": "cc.LightProbeInfo", + "_giScale": 1, + "_giSamples": 1024, + "_bounces": 2, + "_reduceRinging": 0, + "_showProbe": true, + "_showWireframe": true, + "_showConvex": false, + "_data": null, + "_lightProbeSphereVolume": 1 + }, + { + "__type__": "cc.PostSettingsInfo", + "_toneMappingType": 0 + } +] \ No newline at end of file diff --git a/assets/scenes/Level_1_4.scene.meta b/assets/scenes/Level_1_4.scene.meta new file mode 100644 index 0000000..178a756 --- /dev/null +++ b/assets/scenes/Level_1_4.scene.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.1.50", + "importer": "scene", + "imported": true, + "uuid": "a69a7891-0d48-414b-80f3-379d7d6fb0e1", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/Level_1_5.scene b/assets/scenes/Level_1_5.scene new file mode 100644 index 0000000..286ea1b --- /dev/null +++ b/assets/scenes/Level_1_5.scene @@ -0,0 +1,668 @@ +[ + { + "__type__": "cc.SceneAsset", + "_name": "Level_1_5", + "_objFlags": 0, + "__editorExtras__": {}, + "_native": "", + "scene": { + "__id__": 1 + } + }, + { + "__type__": "cc.Scene", + "_name": "Level_1_5", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": null, + "_children": [ + { + "__id__": 2 + }, + { + "__id__": 5 + }, + { + "__id__": 7 + } + ], + "_active": true, + "_components": [], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "autoReleaseAssets": false, + "_globals": { + "__id__": 14 + }, + "_id": "07e3dc45-2be5-4aa9-adb9-432821a53747" + }, + { + "__type__": "cc.Node", + "_name": "Main Light", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 3 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.06397656665577071, + "y": -0.44608233363525845, + "z": -0.8239028751062036, + "w": -0.3436591377065261 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -117.894, + "y": -194.909, + "z": 38.562 + }, + "_id": "8bsy3Z8SBFJ62xTg4sx1Yu" + }, + { + "__type__": "cc.DirectionalLight", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 2 + }, + "_enabled": true, + "__prefab": null, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 250, + "b": 240, + "a": 255 + }, + "_useColorTemperature": false, + "_colorTemperature": 6550, + "_staticSettings": { + "__id__": 4 + }, + "_visibility": -325058561, + "_illuminanceHDR": 65000, + "_illuminance": 65000, + "_illuminanceLDR": 1.6927083333333335, + "_shadowEnabled": false, + "_shadowPcf": 0, + "_shadowBias": 0.00001, + "_shadowNormalBias": 0, + "_shadowSaturation": 1, + "_shadowDistance": 50, + "_shadowInvisibleOcclusionRange": 200, + "_csmLevel": 4, + "_csmLayerLambda": 0.75, + "_csmOptimizationMode": 2, + "_csmAdvancedOptions": false, + "_csmLayersTransition": false, + "_csmTransitionRange": 0.05, + "_shadowFixedArea": false, + "_shadowNear": 0.1, + "_shadowFar": 10, + "_shadowOrthoSize": 5, + "_id": "9czovcBwNG+pelVvSdfZUw" + }, + { + "__type__": "cc.StaticLightSettings", + "_baked": false, + "_editorOnly": false, + "_castShadow": false + }, + { + "__type__": "cc.Node", + "_name": "Main Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 6 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": -10, + "y": 10, + "z": 10 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.27781593346944056, + "y": -0.36497167621709875, + "z": -0.11507512748638377, + "w": 0.8811195706053617 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -35, + "y": -45, + "z": 0 + }, + "_id": "bc3gk4wUNFr7xwdqjLciNA" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 5 + }, + "_enabled": true, + "__prefab": null, + "_projection": 1, + "_priority": 0, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 10, + "_near": 1, + "_far": 1000, + "_color": { + "__type__": "cc.Color", + "r": 51, + "g": 51, + "b": 51, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 14, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 1822425087, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "497ugtwMBCcbtXIos0PI7p" + }, + { + "__type__": "cc.Node", + "_name": "Canvas", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [ + { + "__id__": 8 + } + ], + "_active": true, + "_components": [ + { + "__id__": 10 + }, + { + "__id__": 11 + }, + { + "__id__": 12 + }, + { + "__id__": 13 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 480, + "y": 320, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "4eoNJO4dFN/6wqPv3yewvw" + }, + { + "__type__": "cc.Node", + "_name": "Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 9 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 1000 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "e2kaDEog5ATYQ3FcIJ4QMd" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 8 + }, + "_enabled": true, + "__prefab": null, + "_projection": 0, + "_priority": 1073741824, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 320, + "_near": 1, + "_far": 2000, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 6, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 41943040, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "8e7p1zxUdCgppUkHV27GPz" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 960, + "height": 640 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "0dSF3qWZxBJZZ1D7xfe2T5" + }, + { + "__type__": "cc.Canvas", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_cameraComponent": { + "__id__": 9 + }, + "_alignCanvasWithScreen": true, + "_id": "6e6rAbzY1ECqpl8Si8z46I" + }, + { + "__type__": "cc.Widget", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_alignFlags": 45, + "_target": null, + "_left": 0, + "_right": 0, + "_top": 0, + "_bottom": 0, + "_horizontalCenter": 0, + "_verticalCenter": 0, + "_isAbsLeft": true, + "_isAbsRight": true, + "_isAbsTop": true, + "_isAbsBottom": true, + "_isAbsHorizontalCenter": true, + "_isAbsVerticalCenter": true, + "_originalWidth": 0, + "_originalHeight": 0, + "_alignMode": 2, + "_lockFlags": 0, + "_id": "75R54JSwtLX61WiqohRDw4" + }, + { + "__type__": "621cc+aaUhGP54Gu7lAJ6Np", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "levelId": "1-5", + "nextSceneName": "", + "_id": "2eNkLrxxxByY/GSKY12twK" + }, + { + "__type__": "cc.SceneGlobals", + "ambient": { + "__id__": 15 + }, + "shadows": { + "__id__": 16 + }, + "_skybox": { + "__id__": 17 + }, + "fog": { + "__id__": 18 + }, + "octree": { + "__id__": 19 + }, + "skin": { + "__id__": 20 + }, + "lightProbeInfo": { + "__id__": 21 + }, + "postSettings": { + "__id__": 22 + }, + "bakedWithStationaryMainLight": false, + "bakedWithHighpLightmap": false + }, + { + "__type__": "cc.AmbientInfo", + "_skyColorHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyColor": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyIllumHDR": 20000, + "_skyIllum": 20000, + "_groundAlbedoHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_groundAlbedo": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_skyColorLDR": { + "__type__": "cc.Vec4", + "x": 0.452588, + "y": 0.607642, + "z": 0.755699, + "w": 0 + }, + "_skyIllumLDR": 0.8, + "_groundAlbedoLDR": { + "__type__": "cc.Vec4", + "x": 0.618555, + "y": 0.577848, + "z": 0.544564, + "w": 0 + } + }, + { + "__type__": "cc.ShadowsInfo", + "_enabled": false, + "_type": 0, + "_normal": { + "__type__": "cc.Vec3", + "x": 0, + "y": 1, + "z": 0 + }, + "_distance": 0, + "_planeBias": 1, + "_shadowColor": { + "__type__": "cc.Color", + "r": 76, + "g": 76, + "b": 76, + "a": 255 + }, + "_maxReceived": 4, + "_size": { + "__type__": "cc.Vec2", + "x": 1024, + "y": 1024 + } + }, + { + "__type__": "cc.SkyboxInfo", + "_envLightingType": 0, + "_envmapHDR": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmap": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmapLDR": { + "__uuid__": "6f01cf7f-81bf-4a7e-bd5d-0afc19696480@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_diffuseMapHDR": null, + "_diffuseMapLDR": null, + "_enabled": true, + "_useHDR": true, + "_editableMaterial": null, + "_reflectionHDR": null, + "_reflectionLDR": null, + "_rotationAngle": 0 + }, + { + "__type__": "cc.FogInfo", + "_type": 0, + "_fogColor": { + "__type__": "cc.Color", + "r": 200, + "g": 200, + "b": 200, + "a": 255 + }, + "_enabled": false, + "_fogDensity": 0.3, + "_fogStart": 0.5, + "_fogEnd": 300, + "_fogAtten": 5, + "_fogTop": 1.5, + "_fogRange": 1.2, + "_accurate": false + }, + { + "__type__": "cc.OctreeInfo", + "_enabled": false, + "_minPos": { + "__type__": "cc.Vec3", + "x": -1024, + "y": -1024, + "z": -1024 + }, + "_maxPos": { + "__type__": "cc.Vec3", + "x": 1024, + "y": 1024, + "z": 1024 + }, + "_depth": 8 + }, + { + "__type__": "cc.SkinInfo", + "_enabled": true, + "_blurRadius": 0.01, + "_sssIntensity": 3 + }, + { + "__type__": "cc.LightProbeInfo", + "_giScale": 1, + "_giSamples": 1024, + "_bounces": 2, + "_reduceRinging": 0, + "_showProbe": true, + "_showWireframe": true, + "_showConvex": false, + "_data": null, + "_lightProbeSphereVolume": 1 + }, + { + "__type__": "cc.PostSettingsInfo", + "_toneMappingType": 0 + } +] \ No newline at end of file diff --git a/assets/scenes/Level_1_5.scene.meta b/assets/scenes/Level_1_5.scene.meta new file mode 100644 index 0000000..ab295c2 --- /dev/null +++ b/assets/scenes/Level_1_5.scene.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.1.50", + "importer": "scene", + "imported": true, + "uuid": "07e3dc45-2be5-4aa9-adb9-432821a53747", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/MainMenu.scene b/assets/scenes/MainMenu.scene new file mode 100644 index 0000000..99061c7 --- /dev/null +++ b/assets/scenes/MainMenu.scene @@ -0,0 +1,1324 @@ +[ + { + "__type__": "cc.SceneAsset", + "_name": "MainMenu", + "_objFlags": 0, + "__editorExtras__": {}, + "_native": "", + "scene": { + "__id__": 1 + } + }, + { + "__type__": "cc.Scene", + "_name": "MainMenu", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": null, + "_children": [ + { + "__id__": 2 + }, + { + "__id__": 5 + }, + { + "__id__": 7 + } + ], + "_active": true, + "_components": [], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "autoReleaseAssets": false, + "_globals": { + "__id__": 30 + }, + "_id": "f7b3632a-4628-464c-a23e-371d300ec7a7" + }, + { + "__type__": "cc.Node", + "_name": "Main Light", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 3 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.06397656665577071, + "y": -0.44608233363525845, + "z": -0.8239028751062036, + "w": -0.3436591377065261 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -117.894, + "y": -194.909, + "z": 38.562 + }, + "_id": "11e4nyFMZBNqkBgaqeEiqt" + }, + { + "__type__": "cc.DirectionalLight", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 2 + }, + "_enabled": true, + "__prefab": null, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 250, + "b": 240, + "a": 255 + }, + "_useColorTemperature": false, + "_colorTemperature": 6550, + "_staticSettings": { + "__id__": 4 + }, + "_visibility": -325058561, + "_illuminanceHDR": 65000, + "_illuminance": 65000, + "_illuminanceLDR": 1.6927083333333335, + "_shadowEnabled": false, + "_shadowPcf": 0, + "_shadowBias": 0.00001, + "_shadowNormalBias": 0, + "_shadowSaturation": 1, + "_shadowDistance": 50, + "_shadowInvisibleOcclusionRange": 200, + "_csmLevel": 4, + "_csmLayerLambda": 0.75, + "_csmOptimizationMode": 2, + "_csmAdvancedOptions": false, + "_csmLayersTransition": false, + "_csmTransitionRange": 0.05, + "_shadowFixedArea": false, + "_shadowNear": 0.1, + "_shadowFar": 10, + "_shadowOrthoSize": 5, + "_id": "21qfv4sVtNLYRnME3B3GLg" + }, + { + "__type__": "cc.StaticLightSettings", + "_baked": false, + "_editorOnly": false, + "_castShadow": false + }, + { + "__type__": "cc.Node", + "_name": "Main Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 6 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": -10, + "y": 10, + "z": 10 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.27781593346944056, + "y": -0.36497167621709875, + "z": -0.11507512748638377, + "w": 0.8811195706053617 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -35, + "y": -45, + "z": 0 + }, + "_id": "56MXuNX6JEN6OxrqEPFv09" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 5 + }, + "_enabled": true, + "__prefab": null, + "_projection": 1, + "_priority": 0, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 10, + "_near": 1, + "_far": 1000, + "_color": { + "__type__": "cc.Color", + "r": 51, + "g": 51, + "b": 51, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 14, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 1822425087, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "49y+X8a29O4L0j72Q0HqNp" + }, + { + "__type__": "cc.Node", + "_name": "Canvas", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [ + { + "__id__": 8 + }, + { + "__id__": 10 + }, + { + "__id__": 18 + } + ], + "_active": true, + "_components": [ + { + "__id__": 26 + }, + { + "__id__": 27 + }, + { + "__id__": 28 + }, + { + "__id__": 29 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 480, + "y": 320, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "cf1SlmU65POL9jczj2XdoM" + }, + { + "__type__": "cc.Node", + "_name": "Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 9 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 1000 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "25uzXZBk1IebUmHRa+j+AN" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 8 + }, + "_enabled": true, + "__prefab": null, + "_projection": 0, + "_priority": 1073741824, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 320, + "_near": 1, + "_far": 2000, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 6, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 41943040, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "46JIx2rEVM9rQ/j328iCcn" + }, + { + "__type__": "cc.Node", + "_name": "StartButton", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [ + { + "__id__": 11 + } + ], + "_active": true, + "_components": [ + { + "__id__": 14 + }, + { + "__id__": 15 + }, + { + "__id__": 16 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "8c6p5E5rhEO5aUFvlBSIEM" + }, + { + "__type__": "cc.Node", + "_name": "Label", + "_objFlags": 512, + "__editorExtras__": {}, + "_parent": { + "__id__": 10 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 12 + }, + { + "__id__": 13 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "fbIcFzAr5CfKfNKYlbO40Q" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 11 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "88I4yHlcJCoIe5oHe+3mAj" + }, + { + "__type__": "cc.Label", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 11 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_string": "Start", + "_horizontalAlign": 1, + "_verticalAlign": 1, + "_actualFontSize": 31.25, + "_fontSize": 20, + "_fontFamily": "Arial", + "_lineHeight": 40, + "_overflow": 1, + "_enableWrapText": false, + "_font": null, + "_isSystemFontUsed": true, + "_spacingX": 0, + "_isItalic": false, + "_isBold": false, + "_isUnderline": false, + "_underlineHeight": 2, + "_cacheMode": 0, + "_enableOutline": false, + "_outlineColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_outlineWidth": 2, + "_enableShadow": false, + "_shadowColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_shadowOffset": { + "__type__": "cc.Vec2", + "x": 2, + "y": 2 + }, + "_shadowBlur": 2, + "_id": "31Cp+q4+hKirXP/plVnUqm" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 10 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "e0zxhJA9BGqbPKWLvI4o+J" + }, + { + "__type__": "cc.Sprite", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 10 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_spriteFrame": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_type": 1, + "_fillType": 0, + "_sizeMode": 0, + "_fillCenter": { + "__type__": "cc.Vec2", + "x": 0, + "y": 0 + }, + "_fillStart": 0, + "_fillRange": 0, + "_isTrimmedMode": true, + "_useGrayscale": false, + "_atlas": null, + "_id": "836bi1SUBKJ5k33B6LUGTE" + }, + { + "__type__": "cc.Button", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 10 + }, + "_enabled": true, + "__prefab": null, + "clickEvents": [ + { + "__id__": 17 + } + ], + "_interactable": true, + "_transition": 2, + "_normalColor": { + "__type__": "cc.Color", + "r": 214, + "g": 214, + "b": 214, + "a": 255 + }, + "_hoverColor": { + "__type__": "cc.Color", + "r": 211, + "g": 211, + "b": 211, + "a": 255 + }, + "_pressedColor": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_disabledColor": { + "__type__": "cc.Color", + "r": 124, + "g": 124, + "b": 124, + "a": 255 + }, + "_normalSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_hoverSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_pressedSprite": { + "__uuid__": "544e49d6-3f05-4fa8-9a9e-091f98fc2ce8@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_disabledSprite": { + "__uuid__": "951249e0-9f16-456d-8b85-a6ca954da16b@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_duration": 0.1, + "_zoomScale": 1.2, + "_target": { + "__id__": 10 + }, + "_id": "79gPXKP41AUpfujpDm8Kii" + }, + { + "__type__": "cc.ClickEvent", + "target": { + "__id__": 7 + }, + "component": "", + "_componentId": "3dc136DpR1FMK5dHrzraVUP", + "handler": "onPressStart", + "customEventData": "" + }, + { + "__type__": "cc.Node", + "_name": "SettingButton", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [ + { + "__id__": 19 + } + ], + "_active": true, + "_components": [ + { + "__id__": 22 + }, + { + "__id__": 23 + }, + { + "__id__": 24 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "be6GN2SPZF+pA39B1A4jsb" + }, + { + "__type__": "cc.Node", + "_name": "Label", + "_objFlags": 512, + "__editorExtras__": {}, + "_parent": { + "__id__": 18 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 20 + }, + { + "__id__": 21 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "5eJiBpth9Eb7+CB9aiYzX9" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 19 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "96uYxpGbVDDJc+pgueGRFO" + }, + { + "__type__": "cc.Label", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 19 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_string": "Setting", + "_horizontalAlign": 1, + "_verticalAlign": 1, + "_actualFontSize": 31.25, + "_fontSize": 20, + "_fontFamily": "Arial", + "_lineHeight": 40, + "_overflow": 1, + "_enableWrapText": false, + "_font": null, + "_isSystemFontUsed": true, + "_spacingX": 0, + "_isItalic": false, + "_isBold": false, + "_isUnderline": false, + "_underlineHeight": 2, + "_cacheMode": 0, + "_enableOutline": false, + "_outlineColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_outlineWidth": 2, + "_enableShadow": false, + "_shadowColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_shadowOffset": { + "__type__": "cc.Vec2", + "x": 2, + "y": 2 + }, + "_shadowBlur": 2, + "_id": "a1OE4rdKFDxYV/KkCL6pwe" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 18 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "2cViMPGGxKQbJYw0PdjMTq" + }, + { + "__type__": "cc.Sprite", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 18 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_spriteFrame": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_type": 1, + "_fillType": 0, + "_sizeMode": 0, + "_fillCenter": { + "__type__": "cc.Vec2", + "x": 0, + "y": 0 + }, + "_fillStart": 0, + "_fillRange": 0, + "_isTrimmedMode": true, + "_useGrayscale": false, + "_atlas": null, + "_id": "d5ph82DgxE7b4GtG6eSGEJ" + }, + { + "__type__": "cc.Button", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 18 + }, + "_enabled": true, + "__prefab": null, + "clickEvents": [ + { + "__id__": 25 + } + ], + "_interactable": true, + "_transition": 2, + "_normalColor": { + "__type__": "cc.Color", + "r": 214, + "g": 214, + "b": 214, + "a": 255 + }, + "_hoverColor": { + "__type__": "cc.Color", + "r": 211, + "g": 211, + "b": 211, + "a": 255 + }, + "_pressedColor": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_disabledColor": { + "__type__": "cc.Color", + "r": 124, + "g": 124, + "b": 124, + "a": 255 + }, + "_normalSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_hoverSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_pressedSprite": { + "__uuid__": "544e49d6-3f05-4fa8-9a9e-091f98fc2ce8@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_disabledSprite": { + "__uuid__": "951249e0-9f16-456d-8b85-a6ca954da16b@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_duration": 0.1, + "_zoomScale": 1.2, + "_target": { + "__id__": 18 + }, + "_id": "feHq8+w/pC05BDJcrJCOnm" + }, + { + "__type__": "cc.ClickEvent", + "target": { + "__id__": 7 + }, + "component": "", + "_componentId": "3dc136DpR1FMK5dHrzraVUP", + "handler": "onPressSettings", + "customEventData": "" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 960, + "height": 640 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "32f4U5gp5FIrre84atZYjV" + }, + { + "__type__": "cc.Canvas", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_cameraComponent": { + "__id__": 9 + }, + "_alignCanvasWithScreen": true, + "_id": "64cl7eNyNP+5Gud8djKtoV" + }, + { + "__type__": "cc.Widget", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_alignFlags": 45, + "_target": null, + "_left": 0, + "_right": 0, + "_top": 0, + "_bottom": 0, + "_horizontalCenter": 0, + "_verticalCenter": 0, + "_isAbsLeft": true, + "_isAbsRight": true, + "_isAbsTop": true, + "_isAbsBottom": true, + "_isAbsHorizontalCenter": true, + "_isAbsVerticalCenter": true, + "_originalWidth": 0, + "_originalHeight": 0, + "_alignMode": 2, + "_lockFlags": 0, + "_id": "09iAw68OZMOJn0V6/62EHE" + }, + { + "__type__": "3dc136DpR1FMK5dHrzraVUP", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_id": "9bmLymLShP5ZFJhcJIaEEL" + }, + { + "__type__": "cc.SceneGlobals", + "ambient": { + "__id__": 31 + }, + "shadows": { + "__id__": 32 + }, + "_skybox": { + "__id__": 33 + }, + "fog": { + "__id__": 34 + }, + "octree": { + "__id__": 35 + }, + "skin": { + "__id__": 36 + }, + "lightProbeInfo": { + "__id__": 37 + }, + "postSettings": { + "__id__": 38 + }, + "bakedWithStationaryMainLight": false, + "bakedWithHighpLightmap": false + }, + { + "__type__": "cc.AmbientInfo", + "_skyColorHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyColor": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyIllumHDR": 20000, + "_skyIllum": 20000, + "_groundAlbedoHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_groundAlbedo": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_skyColorLDR": { + "__type__": "cc.Vec4", + "x": 0.452588, + "y": 0.607642, + "z": 0.755699, + "w": 0 + }, + "_skyIllumLDR": 0.8, + "_groundAlbedoLDR": { + "__type__": "cc.Vec4", + "x": 0.618555, + "y": 0.577848, + "z": 0.544564, + "w": 0 + } + }, + { + "__type__": "cc.ShadowsInfo", + "_enabled": false, + "_type": 0, + "_normal": { + "__type__": "cc.Vec3", + "x": 0, + "y": 1, + "z": 0 + }, + "_distance": 0, + "_planeBias": 1, + "_shadowColor": { + "__type__": "cc.Color", + "r": 76, + "g": 76, + "b": 76, + "a": 255 + }, + "_maxReceived": 4, + "_size": { + "__type__": "cc.Vec2", + "x": 1024, + "y": 1024 + } + }, + { + "__type__": "cc.SkyboxInfo", + "_envLightingType": 0, + "_envmapHDR": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmap": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmapLDR": { + "__uuid__": "6f01cf7f-81bf-4a7e-bd5d-0afc19696480@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_diffuseMapHDR": null, + "_diffuseMapLDR": null, + "_enabled": true, + "_useHDR": true, + "_editableMaterial": null, + "_reflectionHDR": null, + "_reflectionLDR": null, + "_rotationAngle": 0 + }, + { + "__type__": "cc.FogInfo", + "_type": 0, + "_fogColor": { + "__type__": "cc.Color", + "r": 200, + "g": 200, + "b": 200, + "a": 255 + }, + "_enabled": false, + "_fogDensity": 0.3, + "_fogStart": 0.5, + "_fogEnd": 300, + "_fogAtten": 5, + "_fogTop": 1.5, + "_fogRange": 1.2, + "_accurate": false + }, + { + "__type__": "cc.OctreeInfo", + "_enabled": false, + "_minPos": { + "__type__": "cc.Vec3", + "x": -1024, + "y": -1024, + "z": -1024 + }, + "_maxPos": { + "__type__": "cc.Vec3", + "x": 1024, + "y": 1024, + "z": 1024 + }, + "_depth": 8 + }, + { + "__type__": "cc.SkinInfo", + "_enabled": true, + "_blurRadius": 0.01, + "_sssIntensity": 3 + }, + { + "__type__": "cc.LightProbeInfo", + "_giScale": 1, + "_giSamples": 1024, + "_bounces": 2, + "_reduceRinging": 0, + "_showProbe": true, + "_showWireframe": true, + "_showConvex": false, + "_data": null, + "_lightProbeSphereVolume": 1 + }, + { + "__type__": "cc.PostSettingsInfo", + "_toneMappingType": 0 + } +] \ No newline at end of file diff --git a/assets/scenes/MainMenu.scene.meta b/assets/scenes/MainMenu.scene.meta new file mode 100644 index 0000000..32fc73b --- /dev/null +++ b/assets/scenes/MainMenu.scene.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.1.50", + "importer": "scene", + "imported": true, + "uuid": "f7b3632a-4628-464c-a23e-371d300ec7a7", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/README.md b/assets/scenes/README.md new file mode 100644 index 0000000..92d1e15 --- /dev/null +++ b/assets/scenes/README.md @@ -0,0 +1,28 @@ +# `assets/scenes` + +This directory hosts Cocos Creator 3.8 scene files (`.scene`). Because scene +files embed UUIDs generated by the editor, they **must** be created from +within Cocos Creator rather than by hand. + +Scenes required by the MVP (task 9.2 and task 7.2): + +| Scene file | Purpose | Created in task | +|------------|---------|-----------------| +| `Boot.scene` | Root scene, binds `GameBoot.ts` | 1.1 | +| `StoryIntro.scene` | Three-page pixel-art opening cutscene | 9.1 | +| `MainMenu.scene` | Main menu, level select, settings | 9.2 | +| `Level_1_1.scene` … `Level_1_5.scene` | Chapter-1 stages | 7.2 | +| `Boss_ShuangHuanFang.scene` | Double-phantom boss arena | 8.1 | +| `Settlement.scene` | End-of-stage and chapter settlement | 9.2 | + +### How to create the Boot scene + +1. Open this project in Cocos Creator 3.8.3. +2. `File ▸ New ▸ Scene`, save as `assets/scenes/Boot.scene`. +3. Add an empty node named `Boot`, attach the `GameBoot` component + (`assets/scripts/GameBoot.ts`). +4. Set it as the project's default scene in `Project ▸ Project Settings ▸ + Project ▸ Start Scene`. + +No code changes are required once the scene and start-scene reference are +set; the whole boot pipeline is script-driven. diff --git a/assets/scenes/README.md.meta b/assets/scenes/README.md.meta new file mode 100644 index 0000000..af1e926 --- /dev/null +++ b/assets/scenes/README.md.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.0.1", + "importer": "text", + "imported": true, + "uuid": "c33e88bc-d213-4976-bbc1-b4f7d3132b3e", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/SETUP_GUIDE.md b/assets/scenes/SETUP_GUIDE.md new file mode 100644 index 0000000..3e34c1f --- /dev/null +++ b/assets/scenes/SETUP_GUIDE.md @@ -0,0 +1,116 @@ +# 场景搭建操作指南(Scene Setup Guide) + +> 适用版本:Cocos Creator 3.8.8 +> 目的:把 `assets/scenes/*.scene` 这 9 个空场景挂上对应的 Scene Entry 组件,让工程可以跑通"Boot → 剧情 → 1-1 → 1-2 → … → Boss → Settlement"完整流程。 + +--- + +## 0. 已完成清单(由代码侧补齐) + +- ✅ 路径别名 `@data/Interfaces` 已改为相对路径(Cocos 3.8 不支持 tsconfig paths) +- ✅ `GameBoot.ts` 已驱动 `UIFlowMgr.onBoot()` → 自动跳 Story / Menu +- ✅ 新增 `assets/scripts/scene_entries/` 目录(6 个 Scene Entry 组件) +- ✅ **每个 Scene Entry 默认开启 `autoBuildUI`**:挂到 Canvas 后无需手动摆放按钮,组件会用 `Graphics + Label + Button` 动态生成: + - MainMenu → 居中 "Start" / "Settings" 按钮 + 顶部标题 + - StoryIntro → 全屏 Label + 右下 "Skip" 按钮 + 全屏 Tap 加速 + - Level_1_x → 顶部 "Level x-y" 标题 + 倒计时/击杀 HUD + - Boss → 顶部标题 + 左右两个 Debug 按钮(Hit Butterfly / Hit Body) + - Settlement → 标题 + Score Label + 结局旁白 + "Back to Menu" 按钮 +- ✅ **占位资产已生成**:37 个 PNG/WAV/MP3 小体积占位文件(总共 156 KB),可通过 `node scripts/gen_placeholder_assets.js` 重新生成。详见 [`assets/resources/ASSETS.md`](../resources/ASSETS.md) 的 § 8 交付跟踪表。 + +--- + +## 1. Boot.scene + +> 已自动挂好 `GameBoot`,无需操作。 + +--- + +## 2. MainMenu.scene + +1. 双击打开 `assets/scenes/MainMenu.scene` +2. Hierarchy 右键根节点 → **创建 → 2D 对象 → Canvas**(若已有 UI 根则跳过) +3. 选中 `Canvas` → Inspector **添加组件** → 搜 **MainMenuEntry** → 添加 +4. `Ctrl/Cmd + S` 保存 + +> `autoBuildUI` 默认开启 → 标题、Start、Settings 按钮会自动生成,无需手动摆放。需要自己做 UI 时,把 Inspector 的 `Auto Build UI` 取消勾选,再手动添加 Button 并把 ClickEvents 绑到 `onPressStart` / `onPressSettings`。 + +--- + +## 3. StoryIntro.scene + +1. 打开 `assets/scenes/StoryIntro.scene` +2. 根节点右键 → **创建 → 2D 对象 → Canvas** +3. 选中 Canvas → 添加组件 **StorySceneEntry** +4. 保存 + +> `autoBuildUI` 默认开启 → 全屏 Label + 右下 Skip 按钮自动生成;点击屏幕任意位置会调用 `onTap` 加速/翻页。 +> 如需指定其他 story,修改 Inspector 里的 `Story Id`(默认 `chapter_1_intro`)。 + +--- + +## 4. Level_1_1.scene ~ Level_1_5.scene(5 个场景) + +对每一个 `Level_1_x.scene`: + +1. 根节点 → **创建 → 2D 对象 → Canvas** +2. 选中 Canvas → 添加组件 **LevelEntry** +3. Inspector 里把 `Level Id` 分别填: + - `Level_1_1` → `1-1`,`Level_1_2` → `1-2`,以此类推到 `1-5` +4. `Next Scene Name` 留空(会自动 1-1→1-2→…→Boss 推导) +5. 保存 + +> `autoBuildUI` 默认开启 → 顶部显示 "Level x-y" 标题 + 倒计时/击杀 HUD 自动生成。 + +--- + +## 5. Boss_ShuangHuanFang.scene + +1. 打开 `assets/scenes/Boss_ShuangHuanFang.scene` +2. 根节点 → **创建 → 2D 对象 → Canvas** +3. 选中 Canvas → 添加组件 **BossEntry** +4. `Boss Id` 保持默认值 `shuang_huan_fang` +5. 保存 + +> `autoBuildUI` 默认开启 → 顶部标题 + 左右两个 Debug 按钮(Hit Butterfly / Hit Body)自动生成,便于在战斗 HUD 就位前验证流程可达性(先点 Butterfly 再点 Body → 进入 Settlement)。 + +--- + +## 6. Settlement.scene + +1. 打开 `assets/scenes/Settlement.scene` +2. 根节点 → **创建 → 2D 对象 → Canvas** +3. 选中 Canvas → 添加组件 **SettlementEntry** +4. 保存 + +> `autoBuildUI` 默认开启 → 标题 + Score Label + 结局旁白("公主被带走,续章待续…")+ "Back to Menu" 按钮自动生成。 + +--- + +## 7. 项目启动场景配置 + +1. 菜单 **项目 → 项目设置 → 项目数据** +2. "启动场景"选 `Boot` +3. 确认 "场景构建列表" 勾选了全部 10 个 `.scene` 文件 + +--- + +## 8. 验证流程 + +按 `Ctrl/Cmd + P` 预览,期望: + +``` +Boot → StoryIntro(3 页打字机) → Level_1_1 → 1_2 → 1_3 → 1_4 → 1_5 + → Boss_ShuangHuanFang → Settlement →(点击返回)→ MainMenu +``` + +--- + +## 9. 常见问题 + +| 症状 | 排查 | +|------|------| +| `Module "@xxx/yyy" not found` | 检查运行时代码是否还有 `@common/` `@data/` `@logic/` `@ui/` 别名。只允许在 `tests/` 里出现;`assets/scripts/` 下必须用相对路径。 | +| 场景切换后黑屏 | 确认 Canvas 节点已创建;3D 相机保留也没关系,但 UI 必须在 Canvas 下 | +| Boss 胜利后未跳 Settlement | 检查 `BossEntry.onBodyHit` 是否被触发;临时用 Debug 按钮验证 | +| Story 页卡住不动 | 检查 `configs/stories.json` 的 `chapter_1_intro` 是否有 ≥ 3 页 | diff --git a/assets/scenes/SETUP_GUIDE.md.meta b/assets/scenes/SETUP_GUIDE.md.meta new file mode 100644 index 0000000..db886d6 --- /dev/null +++ b/assets/scenes/SETUP_GUIDE.md.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.0.1", + "importer": "text", + "imported": true, + "uuid": "c4698742-61e1-42b9-ad99-94723f7d210a", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/Settlement.scene b/assets/scenes/Settlement.scene new file mode 100644 index 0000000..3c2eecc --- /dev/null +++ b/assets/scenes/Settlement.scene @@ -0,0 +1,1271 @@ +[ + { + "__type__": "cc.SceneAsset", + "_name": "Settlement", + "_objFlags": 0, + "__editorExtras__": {}, + "_native": "", + "scene": { + "__id__": 1 + } + }, + { + "__type__": "cc.Scene", + "_name": "Settlement", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": null, + "_children": [ + { + "__id__": 2 + }, + { + "__id__": 5 + }, + { + "__id__": 7 + } + ], + "_active": true, + "_components": [], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "autoReleaseAssets": false, + "_globals": { + "__id__": 28 + }, + "_id": "f608ca61-a900-4941-acdc-f7d56dd25013" + }, + { + "__type__": "cc.Node", + "_name": "Main Light", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 3 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.06397656665577071, + "y": -0.44608233363525845, + "z": -0.8239028751062036, + "w": -0.3436591377065261 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -117.894, + "y": -194.909, + "z": 38.562 + }, + "_id": "8bKjb8tX5JUYn7IaKEz8H4" + }, + { + "__type__": "cc.DirectionalLight", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 2 + }, + "_enabled": true, + "__prefab": null, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 250, + "b": 240, + "a": 255 + }, + "_useColorTemperature": false, + "_colorTemperature": 6550, + "_staticSettings": { + "__id__": 4 + }, + "_visibility": -325058561, + "_illuminanceHDR": 65000, + "_illuminance": 65000, + "_illuminanceLDR": 1.6927083333333335, + "_shadowEnabled": false, + "_shadowPcf": 0, + "_shadowBias": 0.00001, + "_shadowNormalBias": 0, + "_shadowSaturation": 1, + "_shadowDistance": 50, + "_shadowInvisibleOcclusionRange": 200, + "_csmLevel": 4, + "_csmLayerLambda": 0.75, + "_csmOptimizationMode": 2, + "_csmAdvancedOptions": false, + "_csmLayersTransition": false, + "_csmTransitionRange": 0.05, + "_shadowFixedArea": false, + "_shadowNear": 0.1, + "_shadowFar": 10, + "_shadowOrthoSize": 5, + "_id": "21xXwMqSdBEKMwVzwpoIsi" + }, + { + "__type__": "cc.StaticLightSettings", + "_baked": false, + "_editorOnly": false, + "_castShadow": false + }, + { + "__type__": "cc.Node", + "_name": "Main Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 6 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": -10, + "y": 10, + "z": 10 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.27781593346944056, + "y": -0.36497167621709875, + "z": -0.11507512748638377, + "w": 0.8811195706053617 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -35, + "y": -45, + "z": 0 + }, + "_id": "469B6HZpxDk6hEf6LY4K4O" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 5 + }, + "_enabled": true, + "__prefab": null, + "_projection": 1, + "_priority": 0, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 10, + "_near": 1, + "_far": 1000, + "_color": { + "__type__": "cc.Color", + "r": 51, + "g": 51, + "b": 51, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 14, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 1822425087, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "77QmSCpL5O9KaQ8gSjALlS" + }, + { + "__type__": "cc.Node", + "_name": "Canvas", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [ + { + "__id__": 8 + }, + { + "__id__": 10 + }, + { + "__id__": 13 + }, + { + "__id__": 16 + } + ], + "_active": true, + "_components": [ + { + "__id__": 24 + }, + { + "__id__": 25 + }, + { + "__id__": 26 + }, + { + "__id__": 27 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 480, + "y": 320, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "07ZMtuA9RNhIjlXcqf3/Di" + }, + { + "__type__": "cc.Node", + "_name": "Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 9 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 1000 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "fbp4R35oNIw4TSz0BCwuDJ" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 8 + }, + "_enabled": true, + "__prefab": null, + "_projection": 0, + "_priority": 1073741824, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 320, + "_near": 1, + "_far": 2000, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 6, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 41943040, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "7ct4e8MatLXIrKAZQ1i1+J" + }, + { + "__type__": "cc.Node", + "_name": "ScoreLabel", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 11 + }, + { + "__id__": 12 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "f0Eue0qNNMx5UhmR/ZcqWu" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 10 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 101.181640625, + "height": 50.4 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "07MaqbaftG6bF8W4obLnpx" + }, + { + "__type__": "cc.Label", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 10 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_string": "ScoreLabel", + "_horizontalAlign": 1, + "_verticalAlign": 1, + "_actualFontSize": 31.25, + "_fontSize": 20, + "_fontFamily": "Arial", + "_lineHeight": 40, + "_overflow": 0, + "_enableWrapText": true, + "_font": null, + "_isSystemFontUsed": true, + "_spacingX": 0, + "_isItalic": false, + "_isBold": false, + "_isUnderline": false, + "_underlineHeight": 2, + "_cacheMode": 0, + "_enableOutline": false, + "_outlineColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_outlineWidth": 2, + "_enableShadow": false, + "_shadowColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_shadowOffset": { + "__type__": "cc.Vec2", + "x": 2, + "y": 2 + }, + "_shadowBlur": 2, + "_id": "29958A4phBVrwfOeuGe0u7" + }, + { + "__type__": "cc.Node", + "_name": "ClosingLabel", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 14 + }, + { + "__id__": 15 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "01crOByyRHXaIswkpiLObE" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 13 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 115.634765625, + "height": 50.4 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "caUFOSdIdCYZhewIBTjDkN" + }, + { + "__type__": "cc.Label", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 13 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_string": "ClosingLabel", + "_horizontalAlign": 1, + "_verticalAlign": 1, + "_actualFontSize": 31.25, + "_fontSize": 20, + "_fontFamily": "Arial", + "_lineHeight": 40, + "_overflow": 0, + "_enableWrapText": true, + "_font": null, + "_isSystemFontUsed": true, + "_spacingX": 0, + "_isItalic": false, + "_isBold": false, + "_isUnderline": false, + "_underlineHeight": 2, + "_cacheMode": 0, + "_enableOutline": false, + "_outlineColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_outlineWidth": 2, + "_enableShadow": false, + "_shadowColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_shadowOffset": { + "__type__": "cc.Vec2", + "x": 2, + "y": 2 + }, + "_shadowBlur": 2, + "_id": "c263Z78OJOj6RS//s8J1I0" + }, + { + "__type__": "cc.Node", + "_name": "ReturnButton", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [ + { + "__id__": 17 + } + ], + "_active": true, + "_components": [ + { + "__id__": 20 + }, + { + "__id__": 21 + }, + { + "__id__": 22 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "76XfjSdv5DcqcfPPjnNbNu" + }, + { + "__type__": "cc.Node", + "_name": "Label", + "_objFlags": 512, + "__editorExtras__": {}, + "_parent": { + "__id__": 16 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 18 + }, + { + "__id__": 19 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "9e85e0QNFMfZPRdEffWXCc" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 17 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "44Dy/4dX5AjaarqCvyVjTw" + }, + { + "__type__": "cc.Label", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 17 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_string": "Return", + "_horizontalAlign": 1, + "_verticalAlign": 1, + "_actualFontSize": 31.25, + "_fontSize": 20, + "_fontFamily": "Arial", + "_lineHeight": 40, + "_overflow": 1, + "_enableWrapText": false, + "_font": null, + "_isSystemFontUsed": true, + "_spacingX": 0, + "_isItalic": false, + "_isBold": false, + "_isUnderline": false, + "_underlineHeight": 2, + "_cacheMode": 0, + "_enableOutline": false, + "_outlineColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_outlineWidth": 2, + "_enableShadow": false, + "_shadowColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_shadowOffset": { + "__type__": "cc.Vec2", + "x": 2, + "y": 2 + }, + "_shadowBlur": 2, + "_id": "9dUw3VDuJIxaacc6fJTy3G" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 16 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "75F/m4J6pPoI7+vrMhYGZX" + }, + { + "__type__": "cc.Sprite", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 16 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_spriteFrame": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_type": 1, + "_fillType": 0, + "_sizeMode": 0, + "_fillCenter": { + "__type__": "cc.Vec2", + "x": 0, + "y": 0 + }, + "_fillStart": 0, + "_fillRange": 0, + "_isTrimmedMode": true, + "_useGrayscale": false, + "_atlas": null, + "_id": "7aU31AqaVNaJQ2j3Yl9dfk" + }, + { + "__type__": "cc.Button", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 16 + }, + "_enabled": true, + "__prefab": null, + "clickEvents": [ + { + "__id__": 23 + } + ], + "_interactable": true, + "_transition": 2, + "_normalColor": { + "__type__": "cc.Color", + "r": 214, + "g": 214, + "b": 214, + "a": 255 + }, + "_hoverColor": { + "__type__": "cc.Color", + "r": 211, + "g": 211, + "b": 211, + "a": 255 + }, + "_pressedColor": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_disabledColor": { + "__type__": "cc.Color", + "r": 124, + "g": 124, + "b": 124, + "a": 255 + }, + "_normalSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_hoverSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_pressedSprite": { + "__uuid__": "544e49d6-3f05-4fa8-9a9e-091f98fc2ce8@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_disabledSprite": { + "__uuid__": "951249e0-9f16-456d-8b85-a6ca954da16b@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_duration": 0.1, + "_zoomScale": 1.2, + "_target": { + "__id__": 16 + }, + "_id": "d91qXcP1RIAoOyiTN70tzO" + }, + { + "__type__": "cc.ClickEvent", + "target": { + "__id__": 7 + }, + "component": "", + "_componentId": "cab3eHk0ttLL7sUF5z7YkPr", + "handler": "onReturnToMenu", + "customEventData": "" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 960, + "height": 640 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "93EgGs++hLAbJwHNVikksg" + }, + { + "__type__": "cc.Canvas", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_cameraComponent": { + "__id__": 9 + }, + "_alignCanvasWithScreen": true, + "_id": "f9QumxCttHAZZPmOuGlWip" + }, + { + "__type__": "cc.Widget", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_alignFlags": 45, + "_target": null, + "_left": 0, + "_right": 0, + "_top": 0, + "_bottom": 0, + "_horizontalCenter": 0, + "_verticalCenter": 0, + "_isAbsLeft": true, + "_isAbsRight": true, + "_isAbsTop": true, + "_isAbsBottom": true, + "_isAbsHorizontalCenter": true, + "_isAbsVerticalCenter": true, + "_originalWidth": 0, + "_originalHeight": 0, + "_alignMode": 2, + "_lockFlags": 0, + "_id": "7efGukdzlHtLQPODm6m+Tc" + }, + { + "__type__": "cab3eHk0ttLL7sUF5z7YkPr", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "scoreLabelNode": { + "__id__": 10 + }, + "closingLabelNode": { + "__id__": 13 + }, + "_id": "a0tq+pqdBOzKslJ933quqO" + }, + { + "__type__": "cc.SceneGlobals", + "ambient": { + "__id__": 29 + }, + "shadows": { + "__id__": 30 + }, + "_skybox": { + "__id__": 31 + }, + "fog": { + "__id__": 32 + }, + "octree": { + "__id__": 33 + }, + "skin": { + "__id__": 34 + }, + "lightProbeInfo": { + "__id__": 35 + }, + "postSettings": { + "__id__": 36 + }, + "bakedWithStationaryMainLight": false, + "bakedWithHighpLightmap": false + }, + { + "__type__": "cc.AmbientInfo", + "_skyColorHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyColor": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyIllumHDR": 20000, + "_skyIllum": 20000, + "_groundAlbedoHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_groundAlbedo": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_skyColorLDR": { + "__type__": "cc.Vec4", + "x": 0.452588, + "y": 0.607642, + "z": 0.755699, + "w": 0 + }, + "_skyIllumLDR": 0.8, + "_groundAlbedoLDR": { + "__type__": "cc.Vec4", + "x": 0.618555, + "y": 0.577848, + "z": 0.544564, + "w": 0 + } + }, + { + "__type__": "cc.ShadowsInfo", + "_enabled": false, + "_type": 0, + "_normal": { + "__type__": "cc.Vec3", + "x": 0, + "y": 1, + "z": 0 + }, + "_distance": 0, + "_planeBias": 1, + "_shadowColor": { + "__type__": "cc.Color", + "r": 76, + "g": 76, + "b": 76, + "a": 255 + }, + "_maxReceived": 4, + "_size": { + "__type__": "cc.Vec2", + "x": 1024, + "y": 1024 + } + }, + { + "__type__": "cc.SkyboxInfo", + "_envLightingType": 0, + "_envmapHDR": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmap": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmapLDR": { + "__uuid__": "6f01cf7f-81bf-4a7e-bd5d-0afc19696480@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_diffuseMapHDR": null, + "_diffuseMapLDR": null, + "_enabled": true, + "_useHDR": true, + "_editableMaterial": null, + "_reflectionHDR": null, + "_reflectionLDR": null, + "_rotationAngle": 0 + }, + { + "__type__": "cc.FogInfo", + "_type": 0, + "_fogColor": { + "__type__": "cc.Color", + "r": 200, + "g": 200, + "b": 200, + "a": 255 + }, + "_enabled": false, + "_fogDensity": 0.3, + "_fogStart": 0.5, + "_fogEnd": 300, + "_fogAtten": 5, + "_fogTop": 1.5, + "_fogRange": 1.2, + "_accurate": false + }, + { + "__type__": "cc.OctreeInfo", + "_enabled": false, + "_minPos": { + "__type__": "cc.Vec3", + "x": -1024, + "y": -1024, + "z": -1024 + }, + "_maxPos": { + "__type__": "cc.Vec3", + "x": 1024, + "y": 1024, + "z": 1024 + }, + "_depth": 8 + }, + { + "__type__": "cc.SkinInfo", + "_enabled": true, + "_blurRadius": 0.01, + "_sssIntensity": 3 + }, + { + "__type__": "cc.LightProbeInfo", + "_giScale": 1, + "_giSamples": 1024, + "_bounces": 2, + "_reduceRinging": 0, + "_showProbe": true, + "_showWireframe": true, + "_showConvex": false, + "_data": null, + "_lightProbeSphereVolume": 1 + }, + { + "__type__": "cc.PostSettingsInfo", + "_toneMappingType": 0 + } +] \ No newline at end of file diff --git a/assets/scenes/Settlement.scene.meta b/assets/scenes/Settlement.scene.meta new file mode 100644 index 0000000..14906fd --- /dev/null +++ b/assets/scenes/Settlement.scene.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.1.50", + "importer": "scene", + "imported": true, + "uuid": "f608ca61-a900-4941-acdc-f7d56dd25013", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scenes/StoryIntro.scene b/assets/scenes/StoryIntro.scene new file mode 100644 index 0000000..6235cba --- /dev/null +++ b/assets/scenes/StoryIntro.scene @@ -0,0 +1,1463 @@ +[ + { + "__type__": "cc.SceneAsset", + "_name": "StoryIntro", + "_objFlags": 0, + "__editorExtras__": {}, + "_native": "", + "scene": { + "__id__": 1 + } + }, + { + "__type__": "cc.Scene", + "_name": "StoryIntro", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": null, + "_children": [ + { + "__id__": 2 + }, + { + "__id__": 5 + }, + { + "__id__": 7 + } + ], + "_active": true, + "_components": [], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "autoReleaseAssets": false, + "_globals": { + "__id__": 33 + }, + "_id": "ff7e75e3-f791-4da1-b05e-3c18e60651bf" + }, + { + "__type__": "cc.Node", + "_name": "Main Light", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 3 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.06397656665577071, + "y": -0.44608233363525845, + "z": -0.8239028751062036, + "w": -0.3436591377065261 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -117.894, + "y": -194.909, + "z": 38.562 + }, + "_id": "3eh4JRc9BPabTW0NkupQE7" + }, + { + "__type__": "cc.DirectionalLight", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 2 + }, + "_enabled": true, + "__prefab": null, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 250, + "b": 240, + "a": 255 + }, + "_useColorTemperature": false, + "_colorTemperature": 6550, + "_staticSettings": { + "__id__": 4 + }, + "_visibility": -325058561, + "_illuminanceHDR": 65000, + "_illuminance": 65000, + "_illuminanceLDR": 1.6927083333333335, + "_shadowEnabled": false, + "_shadowPcf": 0, + "_shadowBias": 0.00001, + "_shadowNormalBias": 0, + "_shadowSaturation": 1, + "_shadowDistance": 50, + "_shadowInvisibleOcclusionRange": 200, + "_csmLevel": 4, + "_csmLayerLambda": 0.75, + "_csmOptimizationMode": 2, + "_csmAdvancedOptions": false, + "_csmLayersTransition": false, + "_csmTransitionRange": 0.05, + "_shadowFixedArea": false, + "_shadowNear": 0.1, + "_shadowFar": 10, + "_shadowOrthoSize": 5, + "_id": "72T5oCJHJNZKww5A3qXc6d" + }, + { + "__type__": "cc.StaticLightSettings", + "_baked": false, + "_editorOnly": false, + "_castShadow": false + }, + { + "__type__": "cc.Node", + "_name": "Main Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 6 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": -10, + "y": 10, + "z": 10 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": -0.27781593346944056, + "y": -0.36497167621709875, + "z": -0.11507512748638377, + "w": 0.8811195706053617 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": -35, + "y": -45, + "z": 0 + }, + "_id": "879z/hvUxMtYe61sVuxykN" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 5 + }, + "_enabled": true, + "__prefab": null, + "_projection": 1, + "_priority": 0, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 10, + "_near": 1, + "_far": 1000, + "_color": { + "__type__": "cc.Color", + "r": 51, + "g": 51, + "b": 51, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 14, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 1822425087, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "243cEGY65NvKTydDrcWBDC" + }, + { + "__type__": "cc.Node", + "_name": "Canvas", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 1 + }, + "_children": [ + { + "__id__": 8 + }, + { + "__id__": 10 + }, + { + "__id__": 13 + }, + { + "__id__": 21 + } + ], + "_active": true, + "_components": [ + { + "__id__": 29 + }, + { + "__id__": 30 + }, + { + "__id__": 31 + }, + { + "__id__": 32 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 480, + "y": 320, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "75ehBzxhZNX5FuOCf+dHMt" + }, + { + "__type__": "cc.Node", + "_name": "Camera", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 9 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 1000 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 1073741824, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "988G/XA3BHTrACtqEYN/sA" + }, + { + "__type__": "cc.Camera", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 8 + }, + "_enabled": true, + "__prefab": null, + "_projection": 0, + "_priority": 1073741824, + "_fov": 45, + "_fovAxis": 0, + "_orthoHeight": 320, + "_near": 1, + "_far": 2000, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_depth": 1, + "_stencil": 0, + "_clearFlags": 6, + "_rect": { + "__type__": "cc.Rect", + "x": 0, + "y": 0, + "width": 1, + "height": 1 + }, + "_aperture": 19, + "_shutter": 7, + "_iso": 0, + "_screenScale": 1, + "_visibility": 41943040, + "_targetTexture": null, + "_postProcess": null, + "_usePostProcess": false, + "_cameraType": -1, + "_trackingType": 0, + "_id": "6cfl/iMFZMLpnH47WWwH/9" + }, + { + "__type__": "cc.Node", + "_name": "StoryLabel", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 11 + }, + { + "__id__": 12 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "15udbRnOdKE7dVgPRQGMDA" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 10 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 95.615234375, + "height": 50.4 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "d92MHQMMJBeKz3Y+JWPSJw" + }, + { + "__type__": "cc.Label", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 10 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_string": "StoryLabel", + "_horizontalAlign": 1, + "_verticalAlign": 1, + "_actualFontSize": 31.25, + "_fontSize": 20, + "_fontFamily": "Arial", + "_lineHeight": 40, + "_overflow": 0, + "_enableWrapText": true, + "_font": null, + "_isSystemFontUsed": true, + "_spacingX": 0, + "_isItalic": false, + "_isBold": false, + "_isUnderline": false, + "_underlineHeight": 2, + "_cacheMode": 0, + "_enableOutline": false, + "_outlineColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_outlineWidth": 2, + "_enableShadow": false, + "_shadowColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_shadowOffset": { + "__type__": "cc.Vec2", + "x": 2, + "y": 2 + }, + "_shadowBlur": 2, + "_id": "7cDwQrWrxGO7FyVDu8Yhhe" + }, + { + "__type__": "cc.Node", + "_name": "Button", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [ + { + "__id__": 14 + } + ], + "_active": true, + "_components": [ + { + "__id__": 17 + }, + { + "__id__": 18 + }, + { + "__id__": 19 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "6acxJnXUBKk61+UB1rhG9g" + }, + { + "__type__": "cc.Node", + "_name": "Label", + "_objFlags": 512, + "__editorExtras__": {}, + "_parent": { + "__id__": 13 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 15 + }, + { + "__id__": 16 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "7dW0YR+FZBJ4zdZbF4mFqO" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 14 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "553ApTofZKy5TEQM/EU7/1" + }, + { + "__type__": "cc.Label", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 14 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_string": "Skip", + "_horizontalAlign": 1, + "_verticalAlign": 1, + "_actualFontSize": 31.25, + "_fontSize": 20, + "_fontFamily": "Arial", + "_lineHeight": 40, + "_overflow": 1, + "_enableWrapText": false, + "_font": null, + "_isSystemFontUsed": true, + "_spacingX": 0, + "_isItalic": false, + "_isBold": false, + "_isUnderline": false, + "_underlineHeight": 2, + "_cacheMode": 0, + "_enableOutline": false, + "_outlineColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_outlineWidth": 2, + "_enableShadow": false, + "_shadowColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_shadowOffset": { + "__type__": "cc.Vec2", + "x": 2, + "y": 2 + }, + "_shadowBlur": 2, + "_id": "51RKA4gQlINZKAIMMAfrbe" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 13 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "58EOhqCTFIWKmXmhVOHjjM" + }, + { + "__type__": "cc.Sprite", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 13 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_spriteFrame": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_type": 1, + "_fillType": 0, + "_sizeMode": 0, + "_fillCenter": { + "__type__": "cc.Vec2", + "x": 0, + "y": 0 + }, + "_fillStart": 0, + "_fillRange": 0, + "_isTrimmedMode": true, + "_useGrayscale": false, + "_atlas": null, + "_id": "56vle1Pe9C7IkflTPWcyUt" + }, + { + "__type__": "cc.Button", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 13 + }, + "_enabled": true, + "__prefab": null, + "clickEvents": [ + { + "__id__": 20 + } + ], + "_interactable": true, + "_transition": 2, + "_normalColor": { + "__type__": "cc.Color", + "r": 214, + "g": 214, + "b": 214, + "a": 255 + }, + "_hoverColor": { + "__type__": "cc.Color", + "r": 211, + "g": 211, + "b": 211, + "a": 255 + }, + "_pressedColor": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_disabledColor": { + "__type__": "cc.Color", + "r": 124, + "g": 124, + "b": 124, + "a": 255 + }, + "_normalSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_hoverSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_pressedSprite": { + "__uuid__": "544e49d6-3f05-4fa8-9a9e-091f98fc2ce8@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_disabledSprite": { + "__uuid__": "951249e0-9f16-456d-8b85-a6ca954da16b@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_duration": 0.1, + "_zoomScale": 1.2, + "_target": { + "__id__": 13 + }, + "_id": "51+cVzm9dCVrRTHhPfLZXv" + }, + { + "__type__": "cc.ClickEvent", + "target": { + "__id__": 7 + }, + "component": "", + "_componentId": "2c3b5InQg1EIaUANlGXduqd", + "handler": "onSkip", + "customEventData": "" + }, + { + "__type__": "cc.Node", + "_name": "NextPageButton", + "_objFlags": 0, + "__editorExtras__": {}, + "_parent": { + "__id__": 7 + }, + "_children": [ + { + "__id__": 22 + } + ], + "_active": true, + "_components": [ + { + "__id__": 25 + }, + { + "__id__": 26 + }, + { + "__id__": 27 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "37nTjchVtKM5fo6BdIDG4z" + }, + { + "__type__": "cc.Node", + "_name": "Label", + "_objFlags": 512, + "__editorExtras__": {}, + "_parent": { + "__id__": 21 + }, + "_children": [], + "_active": true, + "_components": [ + { + "__id__": 23 + }, + { + "__id__": 24 + } + ], + "_prefab": null, + "_lpos": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_lrot": { + "__type__": "cc.Quat", + "x": 0, + "y": 0, + "z": 0, + "w": 1 + }, + "_lscale": { + "__type__": "cc.Vec3", + "x": 1, + "y": 1, + "z": 1 + }, + "_mobility": 0, + "_layer": 33554432, + "_euler": { + "__type__": "cc.Vec3", + "x": 0, + "y": 0, + "z": 0 + }, + "_id": "49rJRWTa1B7bARK6tQ3yPy" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 22 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "c6z68HcHJGY4LaESBcc64o" + }, + { + "__type__": "cc.Label", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 22 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_string": "Next", + "_horizontalAlign": 1, + "_verticalAlign": 1, + "_actualFontSize": 31.25, + "_fontSize": 20, + "_fontFamily": "Arial", + "_lineHeight": 40, + "_overflow": 1, + "_enableWrapText": false, + "_font": null, + "_isSystemFontUsed": true, + "_spacingX": 0, + "_isItalic": false, + "_isBold": false, + "_isUnderline": false, + "_underlineHeight": 2, + "_cacheMode": 0, + "_enableOutline": false, + "_outlineColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_outlineWidth": 2, + "_enableShadow": false, + "_shadowColor": { + "__type__": "cc.Color", + "r": 0, + "g": 0, + "b": 0, + "a": 255 + }, + "_shadowOffset": { + "__type__": "cc.Vec2", + "x": 2, + "y": 2 + }, + "_shadowBlur": 2, + "_id": "24Q3lAVU5GIqju0jwHJ4xs" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 21 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 100, + "height": 40 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "b0Fd850DBDubLznIQAO2GZ" + }, + { + "__type__": "cc.Sprite", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 21 + }, + "_enabled": true, + "__prefab": null, + "_customMaterial": null, + "_srcBlendFactor": 2, + "_dstBlendFactor": 4, + "_color": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_spriteFrame": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_type": 1, + "_fillType": 0, + "_sizeMode": 0, + "_fillCenter": { + "__type__": "cc.Vec2", + "x": 0, + "y": 0 + }, + "_fillStart": 0, + "_fillRange": 0, + "_isTrimmedMode": true, + "_useGrayscale": false, + "_atlas": null, + "_id": "7dgLp70SFAV6GMQe+32pqL" + }, + { + "__type__": "cc.Button", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 21 + }, + "_enabled": true, + "__prefab": null, + "clickEvents": [ + { + "__id__": 28 + } + ], + "_interactable": true, + "_transition": 2, + "_normalColor": { + "__type__": "cc.Color", + "r": 214, + "g": 214, + "b": 214, + "a": 255 + }, + "_hoverColor": { + "__type__": "cc.Color", + "r": 211, + "g": 211, + "b": 211, + "a": 255 + }, + "_pressedColor": { + "__type__": "cc.Color", + "r": 255, + "g": 255, + "b": 255, + "a": 255 + }, + "_disabledColor": { + "__type__": "cc.Color", + "r": 124, + "g": 124, + "b": 124, + "a": 255 + }, + "_normalSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_hoverSprite": { + "__uuid__": "20835ba4-6145-4fbc-a58a-051ce700aa3e@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_pressedSprite": { + "__uuid__": "544e49d6-3f05-4fa8-9a9e-091f98fc2ce8@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_disabledSprite": { + "__uuid__": "951249e0-9f16-456d-8b85-a6ca954da16b@f9941", + "__expectedType__": "cc.SpriteFrame" + }, + "_duration": 0.1, + "_zoomScale": 1.2, + "_target": { + "__id__": 21 + }, + "_id": "fb+sZEwitNZKC6HazjRzjw" + }, + { + "__type__": "cc.ClickEvent", + "target": { + "__id__": 7 + }, + "component": "", + "_componentId": "2c3b5InQg1EIaUANlGXduqd", + "handler": "onTap", + "customEventData": "" + }, + { + "__type__": "cc.UITransform", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_contentSize": { + "__type__": "cc.Size", + "width": 960, + "height": 640 + }, + "_anchorPoint": { + "__type__": "cc.Vec2", + "x": 0.5, + "y": 0.5 + }, + "_id": "77I6wIUY9OlKuqn+lYsL6W" + }, + { + "__type__": "cc.Canvas", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_cameraComponent": { + "__id__": 9 + }, + "_alignCanvasWithScreen": true, + "_id": "940MHehbpIarKwLJWhU91C" + }, + { + "__type__": "cc.Widget", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "_alignFlags": 45, + "_target": null, + "_left": 0, + "_right": 0, + "_top": 0, + "_bottom": 0, + "_horizontalCenter": 0, + "_verticalCenter": 0, + "_isAbsLeft": true, + "_isAbsRight": true, + "_isAbsTop": true, + "_isAbsBottom": true, + "_isAbsHorizontalCenter": true, + "_isAbsVerticalCenter": true, + "_originalWidth": 0, + "_originalHeight": 0, + "_alignMode": 2, + "_lockFlags": 0, + "_id": "93O30dnLZFdalxLcdKcLjp" + }, + { + "__type__": "2c3b5InQg1EIaUANlGXduqd", + "_name": "", + "_objFlags": 0, + "__editorExtras__": {}, + "node": { + "__id__": 7 + }, + "_enabled": true, + "__prefab": null, + "labelNode": { + "__id__": 10 + }, + "storyId": "chapter_1_intro", + "_id": "745/ByFYBAA4or2IAqhQpm" + }, + { + "__type__": "cc.SceneGlobals", + "ambient": { + "__id__": 34 + }, + "shadows": { + "__id__": 35 + }, + "_skybox": { + "__id__": 36 + }, + "fog": { + "__id__": 37 + }, + "octree": { + "__id__": 38 + }, + "skin": { + "__id__": 39 + }, + "lightProbeInfo": { + "__id__": 40 + }, + "postSettings": { + "__id__": 41 + }, + "bakedWithStationaryMainLight": false, + "bakedWithHighpLightmap": false + }, + { + "__type__": "cc.AmbientInfo", + "_skyColorHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyColor": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.5, + "z": 0.8, + "w": 0.520833125 + }, + "_skyIllumHDR": 20000, + "_skyIllum": 20000, + "_groundAlbedoHDR": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_groundAlbedo": { + "__type__": "cc.Vec4", + "x": 0.2, + "y": 0.2, + "z": 0.2, + "w": 1 + }, + "_skyColorLDR": { + "__type__": "cc.Vec4", + "x": 0.452588, + "y": 0.607642, + "z": 0.755699, + "w": 0 + }, + "_skyIllumLDR": 0.8, + "_groundAlbedoLDR": { + "__type__": "cc.Vec4", + "x": 0.618555, + "y": 0.577848, + "z": 0.544564, + "w": 0 + } + }, + { + "__type__": "cc.ShadowsInfo", + "_enabled": false, + "_type": 0, + "_normal": { + "__type__": "cc.Vec3", + "x": 0, + "y": 1, + "z": 0 + }, + "_distance": 0, + "_planeBias": 1, + "_shadowColor": { + "__type__": "cc.Color", + "r": 76, + "g": 76, + "b": 76, + "a": 255 + }, + "_maxReceived": 4, + "_size": { + "__type__": "cc.Vec2", + "x": 1024, + "y": 1024 + } + }, + { + "__type__": "cc.SkyboxInfo", + "_envLightingType": 0, + "_envmapHDR": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmap": { + "__uuid__": "d032ac98-05e1-4090-88bb-eb640dcb5fc1@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_envmapLDR": { + "__uuid__": "6f01cf7f-81bf-4a7e-bd5d-0afc19696480@b47c0", + "__expectedType__": "cc.TextureCube" + }, + "_diffuseMapHDR": null, + "_diffuseMapLDR": null, + "_enabled": true, + "_useHDR": true, + "_editableMaterial": null, + "_reflectionHDR": null, + "_reflectionLDR": null, + "_rotationAngle": 0 + }, + { + "__type__": "cc.FogInfo", + "_type": 0, + "_fogColor": { + "__type__": "cc.Color", + "r": 200, + "g": 200, + "b": 200, + "a": 255 + }, + "_enabled": false, + "_fogDensity": 0.3, + "_fogStart": 0.5, + "_fogEnd": 300, + "_fogAtten": 5, + "_fogTop": 1.5, + "_fogRange": 1.2, + "_accurate": false + }, + { + "__type__": "cc.OctreeInfo", + "_enabled": false, + "_minPos": { + "__type__": "cc.Vec3", + "x": -1024, + "y": -1024, + "z": -1024 + }, + "_maxPos": { + "__type__": "cc.Vec3", + "x": 1024, + "y": 1024, + "z": 1024 + }, + "_depth": 8 + }, + { + "__type__": "cc.SkinInfo", + "_enabled": true, + "_blurRadius": 0.01, + "_sssIntensity": 3 + }, + { + "__type__": "cc.LightProbeInfo", + "_giScale": 1, + "_giSamples": 1024, + "_bounces": 2, + "_reduceRinging": 0, + "_showProbe": true, + "_showWireframe": true, + "_showConvex": false, + "_data": null, + "_lightProbeSphereVolume": 1 + }, + { + "__type__": "cc.PostSettingsInfo", + "_toneMappingType": 0 + } +] \ No newline at end of file diff --git a/assets/scenes/StoryIntro.scene.meta b/assets/scenes/StoryIntro.scene.meta new file mode 100644 index 0000000..38b40d8 --- /dev/null +++ b/assets/scenes/StoryIntro.scene.meta @@ -0,0 +1,11 @@ +{ + "ver": "1.1.50", + "importer": "scene", + "imported": true, + "uuid": "ff7e75e3-f791-4da1-b05e-3c18e60651bf", + "files": [ + ".json" + ], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts.meta b/assets/scripts.meta new file mode 100644 index 0000000..928f031 --- /dev/null +++ b/assets/scripts.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "8c4e73b0-7cb6-46c1-8a3f-662f10045bbf", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/GameBoot.ts b/assets/scripts/GameBoot.ts new file mode 100644 index 0000000..26dba56 --- /dev/null +++ b/assets/scripts/GameBoot.ts @@ -0,0 +1,91 @@ +import { _decorator, Component, director, screen, view, Settings, settings, sys, game, view as viewMgr } from 'cc'; +import { DESIGN_WIDTH, DESIGN_HEIGHT, TARGET_FPS } from './common/Constants'; +import { UIFlowMgr, ISceneEnter } from './ui/UIFlowMgr'; + +const { ccclass } = _decorator; + +/** + * Game bootstrap component. Attached to the root node of the initial scene + * (`assets/scenes/Boot.scene`). Responsible for: + * + * 1. Locking landscape orientation (requirement - tech-stack constraint). + * 2. Forcing the design resolution to 960x540 with the "fit height" policy + * so that any wider aspect ratio (18:9 / 19.5:9 / 20:9) simply extends + * horizontally without distorting the UI (requirement 1.7 / 18.6). + * 3. Locking frame rate to 30 fps (requirement 18.1-18.3). + * 4. Loading the first gameplay-facing scene (story intro if not seen, else + * main menu). This is the very first place we branch on the local storage + * flag defined in `STORAGE_KEY.StoryIntroSeen` (requirement 19.5). + * + * NOTE: Do NOT put any heavy logic here; this component runs on the very + * first frame and must stay cheap. + */ +@ccclass('GameBoot') +export class GameBoot extends Component { + protected onLoad(): void { + this.lockOrientationLandscape(); + this.applyDesignResolution(); + this.lockFrameRate(); + } + + protected start(): void { + // Drive the very first scene transition through UIFlowMgr so that + // story-intro / main-menu selection lives in one place (req 19.5). + const flow = new UIFlowMgr(undefined, { + onSceneEnter: (ev: ISceneEnter) => this.jumpToScene(ev), + }); + flow.onBoot(); + } + + /** + * Translate the abstract UIFlow event into a concrete Cocos scene load. + * Boot scene itself never needs persisting; once we jump away the node + * is discarded naturally. + */ + private jumpToScene(ev: ISceneEnter): void { + const map: Record = { + story_intro: 'StoryIntro', + main_menu: 'MainMenu', + }; + const scene = map[ev.scene]; + if (scene) { + director.loadScene(scene); + } + } + + /** + * Lock the device to landscape. On WeChat Mini Game this is declared in + * `game.json` (deviceOrientation: "landscape"); on web/simulator we call + * the `screen.orientation` API when available. + */ + private lockOrientationLandscape(): void { + // `screen.orientation` is not declared as writable in every engine + // build, so we access it guardedly. + try { + // @ts-ignore - engine API surface differs across platforms. + if (typeof screen.orientation === 'number') { + // macro.ORIENTATION_LANDSCAPE = 3 in Cocos Creator 3.8. + // @ts-ignore - write is legal on runtime builds. + screen.orientation = 3; + } + } catch (e) { + // Silently ignore: editor preview already enforces orientation + // through `profiles/v2/project.json`. + } + } + + /** + * Apply the 960x540 landscape design resolution. Any physical screen + * whose aspect ratio is >= 16:9 will simply show more horizontal world; + * narrower screens (unlikely on landscape) will letterbox. + */ + private applyDesignResolution(): void { + const v = view; + v.setDesignResolutionSize(DESIGN_WIDTH, DESIGN_HEIGHT, 4 /* FIT_HEIGHT */); + } + + /** Clamp the engine game loop to 30 fps. */ + private lockFrameRate(): void { + game.frameRate = TARGET_FPS; + } +} diff --git a/assets/scripts/GameBoot.ts.meta b/assets/scripts/GameBoot.ts.meta new file mode 100644 index 0000000..175bc87 --- /dev/null +++ b/assets/scripts/GameBoot.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "319b4227-1397-4aa8-882e-e69a74e4bf7d", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/common.meta b/assets/scripts/common.meta new file mode 100644 index 0000000..3af2f38 --- /dev/null +++ b/assets/scripts/common.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "3ea8f4b3-59f2-48dd-aff8-38bf7ec19f1e", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/common/Constants.ts b/assets/scripts/common/Constants.ts new file mode 100644 index 0000000..d7e1cf3 --- /dev/null +++ b/assets/scripts/common/Constants.ts @@ -0,0 +1,101 @@ +/** + * Project-wide constants. This module is platform-agnostic and MUST NOT import + * from `cc` so that it can be unit-tested under Jest. + * + * All numeric values are defined against the landscape baseline design + * resolution 960x540 (16:9). Physical screen adaptation is handled by the + * UI layer (see `@ui/FloatingControlLayer`). + */ + +/** Landscape design resolution baseline width (px). */ +export const DESIGN_WIDTH = 960; + +/** Landscape design resolution baseline height (px). */ +export const DESIGN_HEIGHT = 540; + +/** Target frame rate (locked 30fps per performance requirement 18.1-18.3). */ +export const TARGET_FPS = 30; + +/** Max first-package size (bytes) per requirement 18.7. */ +export const MAX_FIRST_PACKAGE_BYTES = 4 * 1024 * 1024; + +/** Max audio bundle size (bytes) per requirement 16.5 / 19.7. */ +export const MAX_AUDIO_BUNDLE_BYTES = 500 * 1024; + +/** Max runtime memory peak (bytes) per requirement 18.4. */ +export const MAX_MEMORY_PEAK_BYTES = 200 * 1024 * 1024; + +/** + * Player character color states. Red = base (1-hit kill), + * Green = 1 crystal buff, Yellow = 2 crystals (faster movement). + * Per requirement 5.1-5.6. + */ +export enum PlayerColorState { + Red = 'red', + Green = 'green', + Yellow = 'yellow', +} + +/** Horizontal movement speed (px/s) per color state, per requirement 5.1-5.2. */ +export const MOVE_SPEED: Record = { + [PlayerColorState.Red]: 100, + [PlayerColorState.Green]: 100, + [PlayerColorState.Yellow]: 150, +}; + +/** Standard vertical jump height (px) per requirement 2.2 (red/green baseline). */ +export const JUMP_HEIGHT_STANDARD = 250; + +/** Charged jump height (px) per requirement 2.3. */ +export const JUMP_HEIGHT_CHARGED = 375; + +/** Yellow-state jump height (px) per requirement 2.2. */ +export const JUMP_HEIGHT_YELLOW = 300; + +/** Crouch delay before actually leaving the ground (ms) per requirement 2.8. */ +export const JUMP_PREPARE_DELAY_MS = 150; + +/** Long-press threshold to trigger charged jump (ms) per requirement 2.3. */ +export const JUMP_CHARGE_THRESHOLD_MS = 500; + +/** + * Parabolic jump angle tolerance windows (degrees). A joystick direction that + * lies within ±ANGLE_TOLERANCE of 45° or 135° triggers a parabolic jump. + * Per requirement 2.5 and requirement 20.3 (>=95% recognition rate). + */ +export const PARABOLIC_ANGLE_RIGHT = 45; +export const PARABOLIC_ANGLE_LEFT = 135; +export const PARABOLIC_ANGLE_TOLERANCE = 15; + +/** Weapon attack intervals (s). Per requirement 3.4 / 3.6. */ +export const SHURIKEN_INTERVAL_BASE = 0.3; +export const SHURIKEN_INTERVAL_UPGRADED = 0.25; +export const SWORD_INTERVAL = 0.5; + +/** Max shuriken burst count when long-pressing attack button (req 3.5). */ +export const SHURIKEN_BURST_MAX = 3; + +/** Combo-input recognition window (ms) for "jump + attack" per req 4.1. */ +export const COMBO_INPUT_WINDOW_MS = 100; + +/** Player invincibility frames duration (s) after a knockback per req 10.2. */ +export const PLAYER_IFRAME_SECONDS = 0.5; + +/** + * Performance KPI thresholds used by Logger / BI埋点 layer. + * Per requirement 20.1-20.6. + */ +export const PERF_TOUCH_RESPONSE_MAX_MS = 50; +export const PERF_JUMP_STATE_TOGGLE_MAX_MS = 50; +export const PERF_COMBO_RECOGNITION_MAX_MS = 100; +export const PERF_PARABOLIC_ANGLE_ACCURACY_TARGET = 0.95; +export const PERF_AIR_JUMP_BLOCK_RATE_TARGET = 0.99; + +/** Local storage keys (req 17.1-17.5, 19.5). */ +export const STORAGE_KEY = { + LevelUnlock: 'kl_level_unlock', + ControlLayout: 'kl_control_layout', + AudioVolume: 'kl_audio_volume', + TutorialDone: 'kl_tutorial_done', + StoryIntroSeen: 'kl_story_intro_seen', +} as const; diff --git a/assets/scripts/common/Constants.ts.meta b/assets/scripts/common/Constants.ts.meta new file mode 100644 index 0000000..0bd8872 --- /dev/null +++ b/assets/scripts/common/Constants.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "e42a8542-158c-4965-96fd-4b0cb6457b41", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/common/EventBus.ts b/assets/scripts/common/EventBus.ts new file mode 100644 index 0000000..858b1f6 --- /dev/null +++ b/assets/scripts/common/EventBus.ts @@ -0,0 +1,115 @@ +/** + * A lightweight, framework-agnostic pub/sub event bus. + * + * Used project-wide to decouple: + * - UI layer (floating controls) → logic layer (player controller) + * - Logic layer (damage system) → UI layer (HUD / feedback) + * - Any manager → Logger / BI埋点 + * + * Design notes: + * - `emit` is synchronous to avoid a frame of latency for combat events. + * - Subscribing the same callback twice is a no-op (idempotent) to avoid + * double-fire bugs when views are rebuilt. + * - `once` unsubscribes itself after the first invocation. + * - Handler errors are caught and forwarded to a user-provided error hook + * (defaulting to `console.error`) so that one bad listener cannot break + * the rest of the fan-out. + */ + +export type EventHandler = (payload: TPayload) => void; +export type ErrorHook = (event: string, err: unknown) => void; + +interface HandlerRecord { + fn: EventHandler; + once: boolean; +} + +export class EventBus { + private readonly handlers = new Map(); + private errorHook: ErrorHook = (event, err) => { + // Fallback error hook; replaced via `setErrorHook` once Logger is + // available. + // eslint-disable-next-line no-console + console.error(`[EventBus] handler for "${event}" threw:`, err); + }; + + /** Override the default error hook (used by Logger integration). */ + public setErrorHook(hook: ErrorHook): void { + this.errorHook = hook; + } + + /** + * Subscribe a handler. Idempotent — the same `fn` cannot be registered + * twice for the same event. + */ + public on(event: string, fn: EventHandler): void { + this.register(event, fn as EventHandler, false); + } + + /** Subscribe a handler that auto-unsubscribes after one invocation. */ + public once(event: string, fn: EventHandler): void { + this.register(event, fn as EventHandler, true); + } + + /** + * Unsubscribe. If `fn` is omitted, all handlers for `event` are cleared. + */ + public off(event: string, fn?: EventHandler): void { + const list = this.handlers.get(event); + if (!list) { + return; + } + if (!fn) { + this.handlers.delete(event); + return; + } + const filtered = list.filter((r) => r.fn !== fn); + if (filtered.length === 0) { + this.handlers.delete(event); + } else { + this.handlers.set(event, filtered); + } + } + + /** Synchronously dispatch `payload` to every handler registered for `event`. */ + public emit(event: string, payload?: T): void { + const list = this.handlers.get(event); + if (!list || list.length === 0) { + return; + } + // Snapshot first: `once` handlers will mutate `list` via `off`. + const snapshot = list.slice(); + for (const record of snapshot) { + try { + record.fn(payload as unknown); + } catch (err) { + this.errorHook(event, err); + } + if (record.once) { + this.off(event, record.fn); + } + } + } + + /** Return the number of handlers registered for `event`. */ + public listenerCount(event: string): number { + return this.handlers.get(event)?.length ?? 0; + } + + /** Remove every handler of every event (used in unit tests / scene unload). */ + public clear(): void { + this.handlers.clear(); + } + + private register(event: string, fn: EventHandler, once: boolean): void { + const list = this.handlers.get(event) ?? []; + if (list.some((r) => r.fn === fn)) { + return; + } + list.push({ fn, once }); + this.handlers.set(event, list); + } +} + +/** Shared, process-wide event bus. Tests should create a fresh `new EventBus()`. */ +export const globalEventBus = new EventBus(); diff --git a/assets/scripts/common/EventBus.ts.meta b/assets/scripts/common/EventBus.ts.meta new file mode 100644 index 0000000..24fddb1 --- /dev/null +++ b/assets/scripts/common/EventBus.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "459040db-2675-4865-969c-fba50c3a40e1", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/common/Logger.ts b/assets/scripts/common/Logger.ts new file mode 100644 index 0000000..8f17f95 --- /dev/null +++ b/assets/scripts/common/Logger.ts @@ -0,0 +1,170 @@ +/** + * Minimal structured logger + performance-metric emitter. + * + * Two responsibilities live here to keep the common layer thin: + * + * 1. **Leveled logging** — wraps `console.*` with a monotonically increasing + * severity threshold so we can downgrade chatty modules in production. + * 2. **Performance埋点** — records named samples (e.g. touch→response latency) + * and computes p50/p95/avg for QA validation of requirement 20.1-20.6. + * + * Both halves are deliberately kept independent: the `metric()` API can be + * routed to a BI endpoint later without touching the logging API. + */ + +export enum LogLevel { + Debug = 0, + Info = 1, + Warn = 2, + Error = 3, + Silent = 4, +} + +export type LogSink = (level: LogLevel, module: string, msg: string, ...rest: unknown[]) => void; + +export interface MetricSample { + /** Metric name (e.g. 'touch_response_ms'). */ + name: string; + /** Numeric value (ms, count, %, etc.). */ + value: number; + /** Optional tags for slicing (e.g. `{ button: 'jump' }`). */ + tags?: Record; + /** `realTime` timestamp, ms since app start. */ + ts: number; +} + +export interface MetricAggregate { + count: number; + min: number; + max: number; + avg: number; + p50: number; + p95: number; +} + +export class Logger { + private threshold: LogLevel = LogLevel.Debug; + + private sink: LogSink = (level, module, msg, ...rest) => { + const prefix = `[${LogLevel[level]}][${module}]`; + switch (level) { + case LogLevel.Error: + // eslint-disable-next-line no-console + console.error(prefix, msg, ...rest); + break; + case LogLevel.Warn: + // eslint-disable-next-line no-console + console.warn(prefix, msg, ...rest); + break; + case LogLevel.Info: + // eslint-disable-next-line no-console + console.info(prefix, msg, ...rest); + break; + default: + // eslint-disable-next-line no-console + console.log(prefix, msg, ...rest); + } + }; + + private readonly metrics = new Map(); + private readonly startedTimers = new Map(); + + /** Control verbosity globally (call once at boot). */ + public setLevel(level: LogLevel): void { + this.threshold = level; + } + + /** Redirect logs (used in tests to assert against messages). */ + public setSink(sink: LogSink): void { + this.sink = sink; + } + + public debug(mod: string, msg: string, ...rest: unknown[]): void { + this.dispatch(LogLevel.Debug, mod, msg, rest); + } + public info(mod: string, msg: string, ...rest: unknown[]): void { + this.dispatch(LogLevel.Info, mod, msg, rest); + } + public warn(mod: string, msg: string, ...rest: unknown[]): void { + this.dispatch(LogLevel.Warn, mod, msg, rest); + } + public error(mod: string, msg: string, ...rest: unknown[]): void { + this.dispatch(LogLevel.Error, mod, msg, rest); + } + + // ---------- metric API ---------- + + /** Record a single metric sample. */ + public metric(sample: Omit): void { + const list = this.metrics.get(sample.name) ?? []; + list.push(sample.value); + this.metrics.set(sample.name, list); + } + + /** Start a named stopwatch. */ + public timerStart(name: string, now: number = Logger.now()): void { + this.startedTimers.set(name, now); + } + + /** + * Stop a named stopwatch and record its elapsed time (ms) under `name`. + * Returns the elapsed value or `undefined` if the timer was not started. + */ + public timerEnd(name: string, now: number = Logger.now()): number | undefined { + const start = this.startedTimers.get(name); + if (start === undefined) { + return undefined; + } + this.startedTimers.delete(name); + const elapsed = now - start; + this.metric({ name, value: elapsed }); + return elapsed; + } + + /** Compute aggregate stats (used by QA dashboards and test assertions). */ + public aggregate(name: string): MetricAggregate | undefined { + const list = this.metrics.get(name); + if (!list || list.length === 0) { + return undefined; + } + const sorted = list.slice().sort((a, b) => a - b); + const count = sorted.length; + const sum = sorted.reduce((s, v) => s + v, 0); + const pct = (p: number): number => { + // Inclusive nearest-rank definition (matches common QA tools). + const rank = Math.min(count - 1, Math.max(0, Math.ceil((p / 100) * count) - 1)); + return sorted[rank]; + }; + return { + count, + min: sorted[0], + max: sorted[count - 1], + avg: sum / count, + p50: pct(50), + p95: pct(95), + }; + } + + /** Clear all recorded metrics (useful between unit tests). */ + public resetMetrics(): void { + this.metrics.clear(); + this.startedTimers.clear(); + } + + private dispatch(level: LogLevel, mod: string, msg: string, rest: unknown[]): void { + if (level < this.threshold) { + return; + } + this.sink(level, mod, msg, ...rest); + } + + private static now(): number { + if (typeof performance !== 'undefined' && typeof performance.now === 'function') { + return performance.now(); + } + return Date.now(); + } +} + +/** Shared project-wide logger. */ +export const globalLogger = new Logger(); diff --git a/assets/scripts/common/Logger.ts.meta b/assets/scripts/common/Logger.ts.meta new file mode 100644 index 0000000..6f257e3 --- /dev/null +++ b/assets/scripts/common/Logger.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "6d7e102a-3d02-4d19-b555-17a7b19c965b", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/common/ObjectPool.ts b/assets/scripts/common/ObjectPool.ts new file mode 100644 index 0000000..6e4d762 --- /dev/null +++ b/assets/scripts/common/ObjectPool.ts @@ -0,0 +1,115 @@ +/** + * Generic object pool used by damage effects, bullets, enemies and VFX + * (requirement 18.5). Pure-TS, platform-agnostic, Jest-testable. + * + * Design notes: + * - `factory` creates a brand-new instance when the free list is empty. + * - `resetter` is invoked on every released object, letting the caller wipe + * transient state (position, timers, listeners) before it goes back to + * the pool. + * - `maxSize` caps the retained instances; objects released beyond the cap + * are dropped (letting the GC collect them) to bound memory usage + * (requirement 18.4: memory peak ≤ 200MB). + * - Double-release is silently ignored but reported through `onDoubleRelease` + * so tests / Logger can assert correctness. + */ + +export type ObjectFactory = () => T; +export type ObjectResetter = (obj: T) => void; + +export interface ObjectPoolOptions { + /** Required creator invoked when the pool is empty. */ + factory: ObjectFactory; + /** Optional cleaner invoked on every `release`. */ + resetter?: ObjectResetter; + /** Max retained objects; excess releases are discarded. Default 128. */ + maxSize?: number; + /** Optional pre-warm count (creates this many objects upfront). Default 0. */ + preAlloc?: number; + /** Optional diagnostic hook. */ + onDoubleRelease?: (obj: T) => void; +} + +export class ObjectPool { + private readonly free: T[] = []; + private readonly borrowed = new Set(); + private readonly factory: ObjectFactory; + private readonly resetter?: ObjectResetter; + private readonly maxSize: number; + private readonly onDoubleRelease?: (obj: T) => void; + + // Diagnostics + private _acquiredTotal = 0; + private _recycledTotal = 0; + private _createdTotal = 0; + + constructor(options: ObjectPoolOptions) { + this.factory = options.factory; + this.resetter = options.resetter; + this.maxSize = options.maxSize ?? 128; + this.onDoubleRelease = options.onDoubleRelease; + + const preAlloc = options.preAlloc ?? 0; + for (let i = 0; i < preAlloc; i++) { + const inst = this.factory(); + this._createdTotal++; + this.free.push(inst); + } + } + + /** Acquire an object from the pool (creates one if empty). */ + public acquire(): T { + this._acquiredTotal++; + const inst = this.free.pop(); + if (inst !== undefined) { + this.borrowed.add(inst); + return inst; + } + const created = this.factory(); + this._createdTotal++; + this.borrowed.add(created); + return created; + } + + /** Release an object back to the pool. Double-releases are ignored. */ + public release(obj: T): void { + if (!this.borrowed.has(obj)) { + this.onDoubleRelease?.(obj); + return; + } + this.borrowed.delete(obj); + this.resetter?.(obj); + if (this.free.length < this.maxSize) { + this.free.push(obj); + this._recycledTotal++; + } + // else: drop the object so the GC can reclaim it. + } + + /** Number of objects currently held in the free list. */ + public get freeCount(): number { + return this.free.length; + } + + /** Number of objects that are currently out on loan. */ + public get borrowedCount(): number { + return this.borrowed.size; + } + + /** Drop everything. Used on scene unload. */ + public drain(): void { + this.free.length = 0; + this.borrowed.clear(); + } + + /** Diagnostic stats (used by Logger / perf BI埋点). */ + public stats() { + return { + free: this.freeCount, + borrowed: this.borrowedCount, + acquired: this._acquiredTotal, + recycled: this._recycledTotal, + created: this._createdTotal, + }; + } +} diff --git a/assets/scripts/common/ObjectPool.ts.meta b/assets/scripts/common/ObjectPool.ts.meta new file mode 100644 index 0000000..8036f67 --- /dev/null +++ b/assets/scripts/common/ObjectPool.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "42063546-8ae6-4255-be58-f1d3f180ea42", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/common/PerfMonitor.ts b/assets/scripts/common/PerfMonitor.ts new file mode 100644 index 0000000..310c0b6 --- /dev/null +++ b/assets/scripts/common/PerfMonitor.ts @@ -0,0 +1,105 @@ +import { Logger, MetricAggregate } from './Logger'; +import { + PERF_TOUCH_RESPONSE_MAX_MS, + PERF_JUMP_STATE_TOGGLE_MAX_MS, + PERF_COMBO_RECOGNITION_MAX_MS, + PERF_PARABOLIC_ANGLE_ACCURACY_TARGET, + PERF_AIR_JUMP_BLOCK_RATE_TARGET, + MAX_FIRST_PACKAGE_BYTES, + MAX_AUDIO_BUNDLE_BYTES, + MAX_MEMORY_PEAK_BYTES, +} from './Constants'; + +/** + * Performance monitor (task 10.2, req 18 & 20). + * + * Aggregates all the KPI samples recorded through `Logger.metric(...)` and + * reports pass/fail against every threshold listed in the requirements doc. + * CI can run `collectReport()` and assert that `allPassing === true`. + */ + +export interface IPerfThreshold { + metric: string; + /** Budget target (max for latency, min for rates). */ + limit: number; + comparator: '<=' | '>='; + requirementId: string; +} + +export const CORE_PERF_THRESHOLDS: ReadonlyArray = [ + { metric: 'input/touchStart', limit: PERF_TOUCH_RESPONSE_MAX_MS, comparator: '<=', requirementId: 'req 20.1' }, + { metric: 'jump/state_toggle_ms', limit: PERF_JUMP_STATE_TOGGLE_MAX_MS, comparator: '<=', requirementId: 'req 20.2' }, + { metric: 'input/parabolic_accuracy', limit: PERF_PARABOLIC_ANGLE_ACCURACY_TARGET, comparator: '>=', requirementId: 'req 20.3' }, + { metric: 'input/combo_recognition_ms', limit: PERF_COMBO_RECOGNITION_MAX_MS, comparator: '<=', requirementId: 'req 20.4' }, + { metric: 'jump/air_jump_block_rate', limit: PERF_AIR_JUMP_BLOCK_RATE_TARGET, comparator: '>=', requirementId: 'req 20.5' }, +]; + +export interface IPerfCheckResult { + threshold: IPerfThreshold; + aggregate?: MetricAggregate; + passing: boolean; + reason: string; +} + +export interface IPerfReport { + allPassing: boolean; + checks: IPerfCheckResult[]; + /** Optional build/runtime sizes filled in by CI (bytes). */ + firstPackageBytes?: number; + audioBundleBytes?: number; + memoryPeakBytes?: number; + /** Top-level pass/fail for the size budgets. */ + sizeBudgetPassing?: boolean; +} + +export class PerfMonitor { + constructor( + private readonly logger: Logger, + private readonly thresholds: ReadonlyArray = CORE_PERF_THRESHOLDS + ) {} + + public collectReport(buildSizes?: { + firstPackageBytes?: number; + audioBundleBytes?: number; + memoryPeakBytes?: number; + }): IPerfReport { + const checks: IPerfCheckResult[] = this.thresholds.map((t) => this.check(t)); + let sizeBudgetPassing: boolean | undefined; + if (buildSizes) { + sizeBudgetPassing = + (buildSizes.firstPackageBytes ?? 0) <= MAX_FIRST_PACKAGE_BYTES && + (buildSizes.audioBundleBytes ?? 0) <= MAX_AUDIO_BUNDLE_BYTES && + (buildSizes.memoryPeakBytes ?? 0) <= MAX_MEMORY_PEAK_BYTES; + } + const allPassing = checks.every((c) => c.passing) && (sizeBudgetPassing ?? true); + return { + allPassing, + checks, + firstPackageBytes: buildSizes?.firstPackageBytes, + audioBundleBytes: buildSizes?.audioBundleBytes, + memoryPeakBytes: buildSizes?.memoryPeakBytes, + sizeBudgetPassing, + }; + } + + private check(t: IPerfThreshold): IPerfCheckResult { + const agg = this.logger.aggregate(t.metric); + if (!agg) { + return { + threshold: t, + passing: false, + reason: `no samples recorded for "${t.metric}"`, + }; + } + // For latency, use p95. For rate metrics, use avg. + const isRate = t.comparator === '>='; + const observed = isRate ? agg.avg : agg.p95; + const passing = isRate ? observed >= t.limit : observed <= t.limit; + return { + threshold: t, + aggregate: agg, + passing, + reason: `${t.metric} ${isRate ? 'avg' : 'p95'}=${observed.toFixed(2)} vs limit ${t.limit} (${t.comparator})`, + }; + } +} diff --git a/assets/scripts/common/PerfMonitor.ts.meta b/assets/scripts/common/PerfMonitor.ts.meta new file mode 100644 index 0000000..ede2696 --- /dev/null +++ b/assets/scripts/common/PerfMonitor.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "7da5826e-75ae-4fe1-aad7-f9096222ce93", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/common/StorageMgr.ts b/assets/scripts/common/StorageMgr.ts new file mode 100644 index 0000000..2fbbe1f --- /dev/null +++ b/assets/scripts/common/StorageMgr.ts @@ -0,0 +1,123 @@ +/** + * Local-storage facade used by: + * - Level unlock state (req 17.1) + * - Floating control layout (req 17.2) + * - BGM / SFX volume (req 17.3, 16.4) + * - Tutorial completion flags (req 17.4) + * - Story-intro seen flag (req 17.5 / 19.5) + * + * Rationale for the thin facade: + * - The WeChat Mini Game runtime exposes `wx.setStorageSync` while the + * in-editor / browser preview exposes `sys.localStorage`. We isolate + * both behind a single `IStorageDriver` interface so that switching + * platforms is a single line. + * - On read, a failure never throws: it returns the provided default value + * (req 17.6 — "must not crash if local storage is unreadable"). + * - All values go through JSON serialisation so that structured objects + * round-trip without callers having to remember to stringify. + */ + +export interface IStorageDriver { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** Selects the best available driver at runtime. */ +function detectDriver(): IStorageDriver { + // 1. WeChat Mini Game global + const wxGlobal = (globalThis as any).wx; + if (wxGlobal && typeof wxGlobal.setStorageSync === 'function') { + return { + getItem(key) { + try { + const v = wxGlobal.getStorageSync(key); + return v === '' ? null : (v as string); + } catch { + return null; + } + }, + setItem(key, value) { + try { + wxGlobal.setStorageSync(key, value); + } catch { + // swallow — req 17.6 + } + }, + removeItem(key) { + try { + wxGlobal.removeStorageSync(key); + } catch { + // swallow + } + }, + }; + } + + // 2. Browser localStorage + if (typeof globalThis !== 'undefined' && (globalThis as any).localStorage) { + const ls = (globalThis as any).localStorage as Storage; + return { + getItem: (k) => ls.getItem(k), + setItem: (k, v) => ls.setItem(k, v), + removeItem: (k) => ls.removeItem(k), + }; + } + + // 3. In-memory fallback (Jest, Node-only unit tests). + const mem = new Map(); + return { + getItem: (k) => (mem.has(k) ? (mem.get(k) as string) : null), + setItem: (k, v) => { + mem.set(k, v); + }, + removeItem: (k) => { + mem.delete(k); + }, + }; +} + +export class StorageMgr { + private driver: IStorageDriver; + + constructor(driver?: IStorageDriver) { + this.driver = driver ?? detectDriver(); + } + + /** + * Read a JSON-serialisable value. Returns `defaultValue` if the key is + * missing, unparseable, or the underlying driver throws (req 17.6). + */ + public get(key: string, defaultValue: T): T { + try { + const raw = this.driver.getItem(key); + if (raw == null) { + return defaultValue; + } + return JSON.parse(raw) as T; + } catch { + return defaultValue; + } + } + + /** Write a JSON-serialisable value. Silently ignores driver errors. */ + public set(key: string, value: T): void { + try { + this.driver.setItem(key, JSON.stringify(value)); + } catch { + // req 17.6 + } + } + + public remove(key: string): void { + this.driver.removeItem(key); + } + + /** Swap the driver at runtime. Used in unit tests and platform ports. */ + public setDriver(driver: IStorageDriver): void { + this.driver = driver; + } +} + +/** Shared project-wide storage manager. */ +export const globalStorageMgr = new StorageMgr(); diff --git a/assets/scripts/common/StorageMgr.ts.meta b/assets/scripts/common/StorageMgr.ts.meta new file mode 100644 index 0000000..f78fdf7 --- /dev/null +++ b/assets/scripts/common/StorageMgr.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "580d04ad-4749-4b5a-ba2c-5da77a73126a", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/common/TimeMgr.ts b/assets/scripts/common/TimeMgr.ts new file mode 100644 index 0000000..c5a64cc --- /dev/null +++ b/assets/scripts/common/TimeMgr.ts @@ -0,0 +1,76 @@ +/** + * Centralised time manager. Game-logic should read deltas from here instead + * of consuming raw `dt` from Cocos Creator components so that a single + * `pause()` call freezes gameplay without freezing UI (menu / settlement). + * + * Used by: + * - Pause menu, settings overlay, "公主被带走" cutscene (requirement 14.1). + * - Story intro cutscene (requirement 19.x) — UI time keeps ticking while + * gameplay time is held at zero. + * + * Two independent clocks are exposed: + * - `gameTime` : respects pause / time-scale, used by AI, physics, weapons. + * - `realTime` : ignores pause, used by UI animation, typewriter text, and + * Logger timestamps. + */ + +export class TimeMgr { + private _gameTime = 0; + private _realTime = 0; + private _timeScale = 1; + private _paused = false; + + /** Should be called once per frame (e.g. from a root node's `update`). */ + public update(rawDt: number): void { + this._realTime += rawDt; + if (this._paused) { + return; + } + this._gameTime += rawDt * this._timeScale; + } + + public pause(): void { + this._paused = true; + } + + public resume(): void { + this._paused = false; + } + + public get paused(): boolean { + return this._paused; + } + + /** 1.0 = normal, 0.5 = slow-mo, 0 = hard freeze. Negative values clamped. */ + public setTimeScale(scale: number): void { + this._timeScale = Math.max(0, scale); + } + + public get timeScale(): number { + return this._timeScale; + } + + public get gameTime(): number { + return this._gameTime; + } + + public get realTime(): number { + return this._realTime; + } + + /** Produce the scaled dt to pass into logic `update(dt)` calls. */ + public scaledDelta(rawDt: number): number { + return this._paused ? 0 : rawDt * this._timeScale; + } + + /** Reset all clocks (used between scenes and in unit tests). */ + public reset(): void { + this._gameTime = 0; + this._realTime = 0; + this._timeScale = 1; + this._paused = false; + } +} + +/** Shared project-wide time manager. */ +export const globalTimeMgr = new TimeMgr(); diff --git a/assets/scripts/common/TimeMgr.ts.meta b/assets/scripts/common/TimeMgr.ts.meta new file mode 100644 index 0000000..7839118 --- /dev/null +++ b/assets/scripts/common/TimeMgr.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "cefd7ec2-a0d2-4494-b87d-981dcda9d5b2", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/common/index.ts b/assets/scripts/common/index.ts new file mode 100644 index 0000000..7cc715b --- /dev/null +++ b/assets/scripts/common/index.ts @@ -0,0 +1,13 @@ +/** + * Common (platform-agnostic) utilities. Anything imported from this barrel + * file must stay free of `cc` dependencies so that it can be unit-tested + * under Jest (see `tests/__mocks__/cc.ts`). + */ + +export * from './Constants'; +export * from './EventBus'; +export * from './ObjectPool'; +export * from './TimeMgr'; +export * from './StorageMgr'; +export * from './Logger'; +export * from './PerfMonitor'; diff --git a/assets/scripts/common/index.ts.meta b/assets/scripts/common/index.ts.meta new file mode 100644 index 0000000..c22fd8b --- /dev/null +++ b/assets/scripts/common/index.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "b4c9b9e1-0b45-41d2-a05d-eda1098969a1", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/data.meta b/assets/scripts/data.meta new file mode 100644 index 0000000..fddb58a --- /dev/null +++ b/assets/scripts/data.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "8d3f10f6-9875-461e-9d0f-1965bdc2f91a", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/data/ConfigMgr.ts b/assets/scripts/data/ConfigMgr.ts new file mode 100644 index 0000000..aca565e --- /dev/null +++ b/assets/scripts/data/ConfigMgr.ts @@ -0,0 +1,239 @@ +import { + IChapter1ConfigBundle, + IEnemyConfig, + IItemConfig, + IWeaponConfig, + ILevelConfig, + IBossConfig, + IStorySceneConfig, + EnemyType, + ItemType, + WeaponType, + ONLY_DIFFICULTY, +} from './Interfaces'; + +/** + * Abstraction over how JSON is actually fetched. In Cocos Creator 3.8 this + * will be backed by `resources.load('configs/enemies', JsonAsset, cb)`. In + * Jest we inject an in-memory `MapLoader` so tests stay fast and offline. + */ +export interface IJsonLoader { + load(path: string): Promise; +} + +/** + * Minimal in-memory loader, fed with a plain key→JSON map. Used by unit + * tests and by any non-Cocos runtime (e.g. a future web leaderboard tool). + */ +export class MapJsonLoader implements IJsonLoader { + constructor(private readonly map: Record) {} + public async load(path: string): Promise { + if (!(path in this.map)) { + throw new Error(`MapJsonLoader: missing path "${path}"`); + } + return this.map[path] as T; + } +} + +/** + * Loads the chapter-1 config bundle and validates it against the interface + * contract. Any missing required field, unknown enum value, or any reference + * to a removed difficulty mode causes `load()` to reject with a descriptive + * error (requirement 13.6 — no casual/普通 mode may ever load). + */ +export class ConfigMgr { + private _bundle: IChapter1ConfigBundle | undefined; + + constructor(private readonly loader: IJsonLoader) {} + + public get bundle(): IChapter1ConfigBundle { + if (!this._bundle) { + throw new Error('ConfigMgr: load() must be awaited before accessing bundle'); + } + return this._bundle; + } + + public async load(): Promise { + const [enemies, items, weapons, levels, bosses, stories] = await Promise.all([ + this.loader.load('configs/enemies'), + this.loader.load('configs/items'), + this.loader.load('configs/weapons'), + this.loader.load('configs/levels'), + this.loader.load('configs/bosses'), + this.loader.load('configs/stories'), + ]); + const bundle: IChapter1ConfigBundle = { enemies, items, weapons, levels, bosses, stories }; + this.validate(bundle); + this._bundle = bundle; + return bundle; + } + + /** Look up an enemy config, throwing if it's missing. */ + public enemy(id: EnemyType): IEnemyConfig { + const e = this.bundle.enemies.find((x) => x.id === id); + if (!e) throw new Error(`ConfigMgr: enemy "${id}" not found`); + return e; + } + + /** Look up an item config, throwing if it's missing. */ + public item(id: ItemType): IItemConfig { + const it = this.bundle.items.find((x) => x.id === id); + if (!it) throw new Error(`ConfigMgr: item "${id}" not found`); + return it; + } + + /** Look up a weapon config, throwing if it's missing. */ + public weapon(id: WeaponType): IWeaponConfig { + const w = this.bundle.weapons.find((x) => x.id === id); + if (!w) throw new Error(`ConfigMgr: weapon "${id}" not found`); + return w; + } + + /** Look up a level config, throwing if it's missing. */ + public level(id: string): ILevelConfig { + const lv = this.bundle.levels.find((x) => x.id === id); + if (!lv) throw new Error(`ConfigMgr: level "${id}" not found`); + return lv; + } + + /** Look up a boss config, throwing if it's missing. */ + public boss(id: string): IBossConfig { + const b = this.bundle.bosses.find((x) => x.id === id); + if (!b) throw new Error(`ConfigMgr: boss "${id}" not found`); + return b; + } + + /** Look up a story scene, throwing if it's missing. */ + public story(id: string): IStorySceneConfig { + const s = this.bundle.stories.find((x) => x.id === id); + if (!s) throw new Error(`ConfigMgr: story "${id}" not found`); + return s; + } + + // ---------- validation ---------- + + private validate(b: IChapter1ConfigBundle): void { + // Guard against the D-4 decision — casual mode must never load. + const stringified = JSON.stringify(b).toLowerCase(); + if (stringified.includes('"casual"') || stringified.includes('"normal_mode"')) { + throw new Error( + `ConfigMgr: detected forbidden casual/normal_mode token — only difficulty "${ONLY_DIFFICULTY}" is permitted` + ); + } + this.validateEnemies(b.enemies); + this.validateItems(b.items); + this.validateWeapons(b.weapons); + this.validateLevels(b); + this.validateBosses(b.bosses); + this.validateStories(b.stories); + } + + private validateEnemies(list: IEnemyConfig[]): void { + if (list.length === 0) throw new Error('ConfigMgr: enemies list is empty'); + const required: Array = ['id', 'displayName', 'size', 'moveSpeed', 'attackIntervalSec', 'attacks', 'hp']; + for (const e of list) { + for (const key of required) { + if (e[key] === undefined || e[key] === null) { + throw new Error(`ConfigMgr: enemy "${e.id ?? '?'}" missing field "${String(key)}"`); + } + } + if (!Object.values(EnemyType).includes(e.id as EnemyType)) { + throw new Error(`ConfigMgr: enemy id "${e.id}" is not a known EnemyType`); + } + if (e.attacks.length === 0) throw new Error(`ConfigMgr: enemy "${e.id}" must declare at least one attack`); + } + } + + private validateItems(list: IItemConfig[]): void { + if (list.length === 0) throw new Error('ConfigMgr: items list is empty'); + for (const it of list) { + if (!it.id || !it.displayName || !it.icon || typeof it.lifetimeSec !== 'number') { + throw new Error(`ConfigMgr: item "${it.id ?? '?'}" has missing required fields`); + } + if (!Object.values(ItemType).includes(it.id as ItemType)) { + throw new Error(`ConfigMgr: item id "${it.id}" is not a known ItemType`); + } + } + } + + private validateWeapons(list: IWeaponConfig[]): void { + if (list.length === 0) throw new Error('ConfigMgr: weapons list is empty'); + for (const w of list) { + if (!w.id || !w.displayName || typeof w.baseIntervalSec !== 'number' || typeof w.damage !== 'number') { + throw new Error(`ConfigMgr: weapon "${w.id ?? '?'}" has missing required fields`); + } + if (!Object.values(WeaponType).includes(w.id as WeaponType)) { + throw new Error(`ConfigMgr: weapon id "${w.id}" is not a known WeaponType`); + } + } + } + + private validateLevels(b: IChapter1ConfigBundle): void { + if (b.levels.length === 0) throw new Error('ConfigMgr: levels list is empty'); + const enemyIds = new Set(b.enemies.map((e) => e.id)); + const bossIds = new Set(b.bosses.map((x) => x.id)); + for (const lv of b.levels) { + if (!lv.id || !lv.displayName || !lv.sceneTheme || !lv.scrollDirection || !lv.objective) { + throw new Error(`ConfigMgr: level "${lv.id ?? '?'}" has missing required fields`); + } + if (lv.timeLimitSec <= 0) { + throw new Error(`ConfigMgr: level "${lv.id}" must have positive timeLimitSec`); + } + if (lv.objective.kind === 'kill_count') { + if (!lv.objective.enemy || !lv.objective.count) { + throw new Error(`ConfigMgr: level "${lv.id}" kill_count objective missing enemy/count`); + } + if (!enemyIds.has(lv.objective.enemy)) { + throw new Error(`ConfigMgr: level "${lv.id}" references unknown enemy "${lv.objective.enemy}"`); + } + } + if (lv.objective.kind === 'defeat_boss') { + if (!lv.objective.bossId || !bossIds.has(lv.objective.bossId)) { + throw new Error(`ConfigMgr: level "${lv.id}" references unknown boss "${lv.objective.bossId}"`); + } + } + for (const sp of lv.enemySpawns) { + if (!enemyIds.has(sp.type)) { + throw new Error(`ConfigMgr: level "${lv.id}" spawn references unknown enemy "${sp.type}"`); + } + } + } + } + + private validateBosses(list: IBossConfig[]): void { + if (list.length === 0) throw new Error('ConfigMgr: bosses list is empty'); + for (const bo of list) { + if (!bo.id || !bo.displayName || typeof bo.hp !== 'number' || !Array.isArray(bo.phases)) { + throw new Error(`ConfigMgr: boss "${bo.id ?? '?'}" has missing required fields`); + } + if (bo.phases.length === 0) { + throw new Error(`ConfigMgr: boss "${bo.id}" must have at least one phase`); + } + let prev = Number.POSITIVE_INFINITY; + for (const p of bo.phases) { + if (p.hpThreshold > prev) { + throw new Error(`ConfigMgr: boss "${bo.id}" phases must be ordered by descending hpThreshold`); + } + prev = p.hpThreshold; + } + } + } + + private validateStories(list: IStorySceneConfig[]): void { + if (list.length === 0) throw new Error('ConfigMgr: stories list is empty'); + for (const s of list) { + if (!s.id || !s.bgm || !Array.isArray(s.pages) || s.pages.length < 3) { + throw new Error(`ConfigMgr: story "${s.id ?? '?'}" must include id, bgm, and ≥3 pages (req 19.2)`); + } + if (s.maxDurationSec > 30) { + throw new Error(`ConfigMgr: story "${s.id}" maxDurationSec exceeds 30s budget (req 19.1)`); + } + const indices = s.pages.map((p) => p.index).sort((a, b) => a - b); + for (let i = 0; i < indices.length; i++) { + if (indices[i] !== i + 1) { + throw new Error(`ConfigMgr: story "${s.id}" page indices must be contiguous starting from 1`); + } + } + } + } +} diff --git a/assets/scripts/data/ConfigMgr.ts.meta b/assets/scripts/data/ConfigMgr.ts.meta new file mode 100644 index 0000000..1523529 --- /dev/null +++ b/assets/scripts/data/ConfigMgr.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "9877a4f9-e13f-412f-a572-dfe501faba39", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/data/Interfaces.ts b/assets/scripts/data/Interfaces.ts new file mode 100644 index 0000000..508d327 --- /dev/null +++ b/assets/scripts/data/Interfaces.ts @@ -0,0 +1,228 @@ +/** + * Data-driven configuration interfaces for《影之传说》MVP. + * + * Every numeric default in here traces directly back to a requirement in + * `.codebuddy/plan/kage_legend_mvp/requirements.md`. When you change a field, + * keep the inline `req` comment in sync so QA can rebuild the traceability + * matrix. + * + * NOTE: This module is platform-agnostic and MUST NOT depend on `cc`. + */ + +import { PlayerColorState } from '../common/Constants'; + +// --------------------------------------------------------------------------- +// Enemies +// --------------------------------------------------------------------------- + +/** Enum covers all enemy types required by MVP Chapter 1 (req 6.1-6.7). */ +export enum EnemyType { + /** Green ninja — shuriken + sword, 2s interval (req 6.1). */ + QingRen = 'qing_ren', + /** Red ninja — 120px/s + smoke bomb, proactive intercept jump (req 6.2-6.3). */ + ChiRen = 'chi_ren', + /** Black ninja — drops magic flute scroll on castle stages (req 6.5). */ + HeiRen = 'hei_ren', + /** Monster priest — straight-line fireball, 3.0s interval (req 6.6). */ + YaoFang = 'yao_fang', +} + +/** Allowed damage types (req 3.7, 3.8, 10.4-10.5). */ +export type AttackType = 'shuriken' | 'sword' | 'fireball' | 'smoke_bomb'; + +export interface IEnemyConfig { + id: EnemyType; + displayName: string; + /** Pixel sprite size (width x height). */ + size: { w: number; h: number }; + /** Horizontal movement speed (px/s). 0 means stationary. */ + moveSpeed: number; + /** Attack interval in seconds. */ + attackIntervalSec: number; + /** Possible attack types. */ + attacks: AttackType[]; + /** Hit points (1 means dies to any successful hit). */ + hp: number; + /** How many enemies of this type must be killed for chapter objective (optional). */ + killObjective?: number; + /** Drop rules specific to this enemy type (see `IItemDropRule`). */ + drops?: IItemDropRule[]; +} + +// --------------------------------------------------------------------------- +// Items +// --------------------------------------------------------------------------- + +/** Item IDs used by MVP Chapter 1 (req 7.1-7.6, 5.1-5.6). */ +export enum ItemType { + /** 水晶玉 — auto-upgrades red → green → yellow (req 5.1-5.2). */ + CrystalJade = 'crystal_jade', + /** 点丸 — +50% attack power, 30s (req 7.3). */ + DianWan = 'dian_wan', + /** 术丸 — +30% move speed, 20s (req 7.3). */ + ShuWan = 'shu_wan', + /** 魔笛 — screen-wipe one-shot kill (req 7.4). */ + MoDi = 'mo_di', + /** 增丸 — +1 permanent life (req 7.5). */ + ZengWan = 'zeng_wan', +} + +export interface IItemConfig { + id: ItemType; + displayName: string; + /** Icon asset path under `assets/resources/textures/items`. */ + icon: string; + /** Duration in seconds for timed buffs (0 / omitted for instant items). */ + durationSec?: number; + /** + * Relative strength of the effect (interpretation is per item type; + * see `IItemEffectApplier` in logic layer for the actual math). + */ + magnitude?: number; + /** Lifetime in seconds after spawning on the map (req 7.2). */ + lifetimeSec: number; +} + +/** Drop rule attached to an enemy type. Evaluated on enemy death. */ +export interface IItemDropRule { + item: ItemType; + /** Required consecutive kills of this enemy type before drop can happen. */ + afterKills?: number; + /** Probability 0~1 once `afterKills` condition is satisfied. */ + probability: number; +} + +// --------------------------------------------------------------------------- +// Weapons +// --------------------------------------------------------------------------- + +export enum WeaponType { + Shuriken = 'shuriken', + NinjaSword = 'ninja_sword', +} + +export interface IWeaponConfig { + id: WeaponType; + displayName: string; + /** Base attack interval (s). Yellow state may override for shuriken. */ + baseIntervalSec: number; + /** Upgraded (green/yellow) interval (s). */ + upgradedIntervalSec?: number; + /** Damage applied to standard enemies on hit. */ + damage: number; + /** Supports parry (req 3.7 — only sword). */ + canParry: boolean; + /** When long-pressed, max shots in a burst (req 3.5 — shuriken only). */ + burstMax?: number; +} + +// --------------------------------------------------------------------------- +// Levels (Chapter 1 only) +// --------------------------------------------------------------------------- + +/** Scroll direction for a level (req 8.1-8.5). */ +export type ScrollDirection = 'horizontal' | 'horizontal_bi' | 'vertical'; + +export interface ILevelObjective { + /** e.g. 'kill_yao_fang', 'reach_top', 'boss_defeated'. */ + kind: 'kill_count' | 'reach_top' | 'defeat_boss'; + /** For kill_count objectives — which enemy type, and how many. */ + enemy?: EnemyType; + /** Required count for kill_count objectives. */ + count?: number; + /** For defeat_boss objectives — boss ID. */ + bossId?: string; +} + +export interface ILevelConfig { + id: string; // e.g. '1-1' + chapter: 1 | 2 | 3; + displayName: string; + /** One of 'forest' / 'cave' / 'castle_wall' / 'demon_castle'. */ + sceneTheme: string; + scrollDirection: ScrollDirection; + /** Time limit in seconds (req 8.1-8.4). */ + timeLimitSec: number; + objective: ILevelObjective; + /** Scene length in pixels (landscape 16:9 baseline, req 8.8). */ + levelLengthPx: number; + /** BGM bundle key under `assets/resources/audio`. */ + bgm: string; + /** Enemy spawn list evaluated by the LevelMgr. */ + enemySpawns: Array<{ type: EnemyType; atPx: number; count?: number }>; +} + +// --------------------------------------------------------------------------- +// Bosses +// --------------------------------------------------------------------------- + +export interface IBossAttackPhase { + /** HP threshold at which this phase activates (1.0, 0.66, 0.33, ...). */ + hpThreshold: number; + /** Human-readable mode id (e.g. 'pair_pincer', 'fireball_spread', 'clone_confuse'). */ + mode: string; + /** Interval between actions in this phase (s). */ + actionIntervalSec: number; +} + +export interface IBossConfig { + id: string; + displayName: string; + hp: number; + /** A non-zero value means "butterfly appearance required before damage". */ + butterflyReveal: boolean; + /** Ordered from highest hpThreshold to lowest. */ + phases: IBossAttackPhase[]; + /** Cutscene trigger (req 8.6 / 14.1): play short "princess taken" at hp<=X. */ + princessCutsceneAtHpRatio?: number; +} + +// --------------------------------------------------------------------------- +// Story intro (req 19) +// --------------------------------------------------------------------------- + +export interface IStoryPageConfig { + /** 1-based page index. */ + index: number; + /** Texture path under `assets/resources/textures/story`. */ + illustration: string; + /** Pixel typewriter text to display. */ + text: string; +} + +export interface IStorySceneConfig { +id: string; // e.g. 'chapter_1_intro' + bgm: string; // e.g. 'bgm_story' + /** Max total duration (seconds); UI should auto-advance if exceeded. */ + maxDurationSec: number; + pages: IStoryPageConfig[]; +} + +// --------------------------------------------------------------------------- +// Aggregate configuration table +// --------------------------------------------------------------------------- + +export interface IChapter1ConfigBundle { + enemies: IEnemyConfig[]; + items: IItemConfig[]; + weapons: IWeaponConfig[]; + levels: ILevelConfig[]; + bosses: IBossConfig[]; + stories: IStorySceneConfig[]; +} + +/** + * Describes which player-state the JSON config applies to. We explicitly + * leave **no room** for a 'casual' mode string so that future edits cannot + * silently reintroduce the removed difficulty (decision D-4, req 13.1-13.6). + */ +export type DifficultyProfile = 'hardcore'; + +export const ONLY_DIFFICULTY: DifficultyProfile = 'hardcore'; + +/** Convenience map for looking up the red/green/yellow state that unlocks each move-speed bucket. */ +export const COLOR_STATE_MOVE_BUCKET: Record = { + [PlayerColorState.Red]: 100, + [PlayerColorState.Green]: 100, + [PlayerColorState.Yellow]: 150, +}; diff --git a/assets/scripts/data/Interfaces.ts.meta b/assets/scripts/data/Interfaces.ts.meta new file mode 100644 index 0000000..9f5b721 --- /dev/null +++ b/assets/scripts/data/Interfaces.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "f26544af-4281-4c60-b85e-1cf89aadda32", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/data/index.ts b/assets/scripts/data/index.ts new file mode 100644 index 0000000..7a92c63 --- /dev/null +++ b/assets/scripts/data/index.ts @@ -0,0 +1,8 @@ +/** + * Data layer — TypeScript interfaces, data-driven JSON loaders, and schema + * validators. See `Interfaces.ts` for the contract and `ConfigMgr.ts` for + * the runtime loader + validator. + */ + +export * from './Interfaces'; +export * from './ConfigMgr'; diff --git a/assets/scripts/data/index.ts.meta b/assets/scripts/data/index.ts.meta new file mode 100644 index 0000000..956bc1d --- /dev/null +++ b/assets/scripts/data/index.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "d907f3f3-211b-46eb-be38-01e62ae11409", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic.meta b/assets/scripts/logic.meta new file mode 100644 index 0000000..e525750 --- /dev/null +++ b/assets/scripts/logic.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "d6003e00-00cd-4b56-b944-0bc3c53e50dc", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/AttackController.ts b/assets/scripts/logic/AttackController.ts new file mode 100644 index 0000000..3e74c4a --- /dev/null +++ b/assets/scripts/logic/AttackController.ts @@ -0,0 +1,149 @@ +import { + SHURIKEN_INTERVAL_BASE, + SHURIKEN_INTERVAL_UPGRADED, + SWORD_INTERVAL, + SHURIKEN_BURST_MAX, + COMBO_INPUT_WINDOW_MS, + PlayerColorState, +} from '../common/Constants'; +import { WeaponType, AttackType } from '../data/Interfaces'; + +/** + * Attack controller — models the two mutually-exclusive weapon buttons + * (手里剑 / 忍者刀) plus the combo recognition window (req 3.1-3.9, 4.1-4.5). + * + * Outputs a single `IAttackDispatchEvent` per "fire" that gameplay code + * applies through `DamageSystem` (task 6.2). Nothing here talks to `cc`; + * it is deterministic on `now` timestamps provided by the caller. + */ + +export type ActiveWeapon = 'none' | WeaponType; + +export interface IAttackDispatchEvent { + weapon: WeaponType; + attackType: AttackType; + /** true when this attack was chained with a jump within the combo window (req 4.1). */ + comboWithJump: boolean; + /** 1-based index within a shuriken burst. Always 1 for the sword. */ + burstIndex: number; + /** realtime timestamp of the swing / throw (ms). */ + ts: number; +} + +/** Interface used by AttackController to know whether combo window applies. */ +export interface IJumpStateProvider { + /** Timestamp of the latest jump press (ms). `undefined` means no pending jump. */ + lastJumpPressTs(): number | undefined; + /** Current grounded flag. */ + isGrounded(): boolean; +} + +/** + * Null jump-state provider — used when attack is fired on the ground and no + * combo is expected (e.g. unit tests). + */ +export const NullJumpState: IJumpStateProvider = { + lastJumpPressTs: () => undefined, + isGrounded: () => true, +}; + +export class AttackController { + private active: ActiveWeapon = 'none'; + /** Earliest press timestamp among currently-held attack buttons. */ + private pressedAt = new Map(); + /** Next-eligible-fire timestamp per weapon. */ + private readyAt = new Map(); + /** Current shuriken burst index (1..SHURIKEN_BURST_MAX). */ + private shurikenBurstIndex = 0; + + constructor( + private readonly jumpState: IJumpStateProvider = NullJumpState, + private readonly comboWindowMs: number = COMBO_INPUT_WINDOW_MS + ) {} + + /** Returns the currently active weapon (or `'none'`). */ + public getActive(): ActiveWeapon { + return this.active; + } + + public isPressed(weapon: WeaponType): boolean { + return this.pressedAt.has(weapon); + } + + /** + * Press handler. Implements mutual exclusion (req 3.1-3.3): the first + * button pressed wins until it is released. + */ + public press(weapon: WeaponType, nowMs: number): void { + if (!this.pressedAt.has(weapon)) { + this.pressedAt.set(weapon, nowMs); + } + if (this.active === 'none') { + this.active = weapon; + if (weapon === WeaponType.Shuriken) { + this.shurikenBurstIndex = 0; + } + } + } + + public release(weapon: WeaponType): void { + this.pressedAt.delete(weapon); + if (this.active === weapon) { + // If the other button is still pressed, transfer activation. + const remaining = Array.from(this.pressedAt.keys())[0]; + this.active = remaining ?? 'none'; + if (weapon === WeaponType.Shuriken) { + this.shurikenBurstIndex = 0; + } + } + } + + /** + * Called every frame. Returns the list of attacks to dispatch **this + * frame** (usually 0 or 1; can be >1 only if dt is huge in tests). + */ + public tick(nowMs: number, colorState: PlayerColorState): IAttackDispatchEvent[] { + if (this.active === 'none') return []; + const weapon = this.active; + const ready = this.readyAt.get(weapon) ?? 0; + if (nowMs < ready) return []; + return [this.fire(weapon, nowMs, colorState)]; + } + + /** Cancel everything (scene unload, pause, death). */ + public reset(): void { + this.active = 'none'; + this.pressedAt.clear(); + this.readyAt.clear(); + this.shurikenBurstIndex = 0; + } + + // ------------------------------------------------------------------ + + private fire(weapon: WeaponType, nowMs: number, colorState: PlayerColorState): IAttackDispatchEvent { + const interval = this.intervalFor(weapon, colorState); + const comboWithJump = this.isComboWithJump(nowMs); + + let burstIndex = 1; + if (weapon === WeaponType.Shuriken) { + this.shurikenBurstIndex = Math.min(SHURIKEN_BURST_MAX, this.shurikenBurstIndex + 1); + burstIndex = this.shurikenBurstIndex; + } + + this.readyAt.set(weapon, nowMs + interval * 1000); + const attackType: AttackType = weapon === WeaponType.Shuriken ? 'shuriken' : 'sword'; + return { weapon, attackType, comboWithJump, burstIndex, ts: nowMs }; + } + + private intervalFor(weapon: WeaponType, color: PlayerColorState): number { + if (weapon === WeaponType.NinjaSword) return SWORD_INTERVAL; + return color === PlayerColorState.Yellow ? SHURIKEN_INTERVAL_UPGRADED : SHURIKEN_INTERVAL_BASE; + } + + private isComboWithJump(nowMs: number): boolean { + const lastJump = this.jumpState.lastJumpPressTs(); + if (lastJump === undefined) return false; + const dt = Math.abs(nowMs - lastJump); + return dt <= this.comboWindowMs; + } +} diff --git a/assets/scripts/logic/AttackController.ts.meta b/assets/scripts/logic/AttackController.ts.meta new file mode 100644 index 0000000..34fabcf --- /dev/null +++ b/assets/scripts/logic/AttackController.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "db605e8d-fb86-469f-9dc2-d5430752d693", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/BossController.ts b/assets/scripts/logic/BossController.ts new file mode 100644 index 0000000..10a9090 --- /dev/null +++ b/assets/scripts/logic/BossController.ts @@ -0,0 +1,107 @@ +import { IBossConfig } from '../data/Interfaces'; + +/** + * Boss controller — 双幻坊 (req 9.1-9.6 + 8.6-8.7). + * + * Key beats: + * 1. A lone butterfly orbits the boss. Until the butterfly is hit, the boss + * is invulnerable (`butterflyRevealed = false`). + * 2. First hit on the butterfly → `revealedAt` timestamp stamped, boss + * becomes vulnerable. + * 3. While revealed, ANY single clean hit kills the boss (req 9.3). We still + * honour `phase` transitions at 2/3 and 1/3 HP for visual variety. + * 4. When HP ≤ `princessCutsceneAtHpRatio`, we emit a one-shot `princess_taken` + * event (≤ 3s, battle keeps running — req 8.6 / 14.1). + * 5. On death we emit `boss_killed` + `chapter_end_cutscene` (≤ 2s). Neither + * code path produces a "rope-severing rescue" event — decision D-5 / req + * 14.5. + */ + +export type BossOutcomeEvent = + | { kind: 'phase_changed'; phase: string; actionIntervalSec: number } + | { kind: 'butterfly_revealed' } + | { kind: 'princess_taken_cutscene' } + | { kind: 'boss_killed' }; + +export class BossController { + private hp: number; + private butterflyRevealed = false; + private phaseIndex = 0; + private princessCutscenePlayed = false; + private killed = false; + public readonly cfg: IBossConfig; + + constructor(cfg: IBossConfig) { + this.cfg = cfg; + this.hp = cfg.hp; + } + + public get currentHp(): number { + return this.hp; + } + public get currentPhase() { + return this.cfg.phases[this.phaseIndex]; + } + public get isButterflyRevealed(): boolean { + return this.butterflyRevealed; + } + public get isDead(): boolean { + return this.killed; + } + + /** Called when a player's attack hits the butterfly (req 9.2). */ + public onButterflyHit(): BossOutcomeEvent[] { + if (this.butterflyRevealed) return []; + this.butterflyRevealed = true; + return [{ kind: 'butterfly_revealed' }]; + } + + /** + * Called when a player's attack hits the boss body. Before the butterfly + * is revealed this call is a no-op; after reveal, the boss dies in one + * hit (req 9.3). + */ + public onBodyHit(): BossOutcomeEvent[] { + if (!this.butterflyRevealed) return []; + if (this.killed) return []; + const out: BossOutcomeEvent[] = []; + this.hp = Math.max(0, this.hp - 1); + out.push(...this.checkPhaseTransition(), ...this.checkPrincessCutscene()); + if (this.hp === 0) { + this.killed = true; + out.push({ kind: 'boss_killed' }); + } + return out; + } + + // -------------------------------------------------------------------- + + private checkPhaseTransition(): BossOutcomeEvent[] { + const hpRatio = this.hp / this.cfg.hp; + for (let i = this.phaseIndex + 1; i < this.cfg.phases.length; i++) { + if (hpRatio <= this.cfg.phases[i].hpThreshold) { + this.phaseIndex = i; + return [ + { + kind: 'phase_changed', + phase: this.cfg.phases[i].mode, + actionIntervalSec: this.cfg.phases[i].actionIntervalSec, + }, + ]; + } + } + return []; + } + + private checkPrincessCutscene(): BossOutcomeEvent[] { + if (this.princessCutscenePlayed) return []; + const threshold = this.cfg.princessCutsceneAtHpRatio; + if (threshold === undefined) return []; + const hpRatio = this.hp / this.cfg.hp; + if (hpRatio <= threshold) { + this.princessCutscenePlayed = true; + return [{ kind: 'princess_taken_cutscene' }]; + } + return []; + } +} diff --git a/assets/scripts/logic/BossController.ts.meta b/assets/scripts/logic/BossController.ts.meta new file mode 100644 index 0000000..5b51dfb --- /dev/null +++ b/assets/scripts/logic/BossController.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "452bb8f0-7423-4ca0-87b4-70bc9dc8d382", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/CameraScroller.ts b/assets/scripts/logic/CameraScroller.ts new file mode 100644 index 0000000..b68b71c --- /dev/null +++ b/assets/scripts/logic/CameraScroller.ts @@ -0,0 +1,98 @@ +import { ILevelConfig, ScrollDirection } from '../data/Interfaces'; + +/** + * Camera-scrolling model (task 7.1). + * + * Captures the level's camera/scrolling state without depending on `cc`. The + * Cocos view layer maps `CameraScroller.offsetX / offsetY` into a `Camera` + * component position every frame. + * + * Supported scroll modes (req 8.1-8.5, 8.8): + * - `horizontal` — scroll never rewinds (森林/魔城). + * - `horizontal_bi` — left/right both allowed (洞穴水路). + * - `vertical` — scrolls upward as the player climbs (城壁). + * + * Values are in **landscape design pixels** (960x540 baseline). + */ + +export interface ICameraConfig { + /** Scroll direction, mirrors `ILevelConfig.scrollDirection`. */ + direction: ScrollDirection; + /** Horizontal level length (for `horizontal` and `horizontal_bi`). */ + lengthX: number; + /** Vertical level length (for `vertical`). */ + lengthY?: number; + /** Camera viewport (design px). */ + viewportW: number; + viewportH: number; +} + +/** Four-layer parallax scroller (req 8.8). Speed ratios 1 : 2 : 4 : 4. */ +export const PARALLAX_RATIOS = [1, 2, 4, 4] as const; +export type ParallaxLayer = 'far' | 'mid' | 'near' | 'fx'; +export const PARALLAX_LAYERS: ParallaxLayer[] = ['far', 'mid', 'near', 'fx']; + +export class CameraScroller { + private _offsetX = 0; + private _offsetY = 0; + private readonly cfg: ICameraConfig; + + constructor(cfg: ICameraConfig) { + this.cfg = cfg; + } + + public get offsetX(): number { + return this._offsetX; + } + + public get offsetY(): number { + return this._offsetY; + } + + /** Camera target follows the player but never rewinds on `horizontal`. */ + public followPlayer(playerX: number, playerY: number): void { + const halfW = this.cfg.viewportW / 2; + const halfH = this.cfg.viewportH / 2; + if (this.cfg.direction === 'horizontal') { + const desired = Math.max(0, playerX - halfW); + this._offsetX = Math.min( + Math.max(this._offsetX, desired), + Math.max(0, this.cfg.lengthX - this.cfg.viewportW) + ); + } else if (this.cfg.direction === 'horizontal_bi') { + const desired = Math.max(0, playerX - halfW); + this._offsetX = Math.min(desired, Math.max(0, this.cfg.lengthX - this.cfg.viewportW)); + } else if (this.cfg.direction === 'vertical') { + const ly = this.cfg.lengthY ?? this.cfg.viewportH; + const desiredY = Math.max(0, playerY - halfH); + this._offsetY = Math.min(desiredY, Math.max(0, ly - this.cfg.viewportH)); + } + } + + /** Compute the world offset for a given parallax layer. */ + public offsetForLayer(layer: ParallaxLayer): { x: number; y: number } { + const ratio = PARALLAX_RATIOS[PARALLAX_LAYERS.indexOf(layer)]; + return { x: this._offsetX / ratio, y: this._offsetY / ratio }; + } + + /** Return the level's culling rect in world coordinates. */ + public cullRect(): { leftX: number; rightX: number; topY: number; bottomY: number } { + return { + leftX: this._offsetX, + rightX: this._offsetX + this.cfg.viewportW, + topY: this._offsetY + this.cfg.viewportH, + bottomY: this._offsetY, + }; + } +} + +/** Build a CameraScroller from a level config. */ +export function cameraFromLevel(level: ILevelConfig, viewportW = 960, viewportH = 540): CameraScroller { + return new CameraScroller({ + direction: level.scrollDirection, + lengthX: level.levelLengthPx, + lengthY: level.scrollDirection === 'vertical' ? level.levelLengthPx : undefined, + viewportW, + viewportH, + }); +} diff --git a/assets/scripts/logic/CameraScroller.ts.meta b/assets/scripts/logic/CameraScroller.ts.meta new file mode 100644 index 0000000..9e7eb60 --- /dev/null +++ b/assets/scripts/logic/CameraScroller.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "8f42b819-8901-4709-b784-ff4c5f7fb61e", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/ChapterSettlement.ts b/assets/scripts/logic/ChapterSettlement.ts new file mode 100644 index 0000000..9a8535e --- /dev/null +++ b/assets/scripts/logic/ChapterSettlement.ts @@ -0,0 +1,73 @@ +/** + * Chapter settlement logic (task 8.2, req 14.1-14.5). + * + * After the boss dies the chapter-1 cutscene sequence is strictly: + * + * princess_taken_cutscene (≤ 3s) — optional, plays mid-fight when HP ≤ 1/2 + * boss_killed (≤ 2s) — freeze-frame + * settlement_screen — score + stats UI + * + * There is **no** rope-severing rescue event in the MVP (req 14.5), so we + * expose a single `BANNED_RESCUE_SEQUENCE` constant the QA test asserts + * against; any future code that tries to play it will fail the guardrail. + */ + +export type CutsceneId = 'princess_taken' | 'boss_killed_freeze' | 'settlement_screen'; + +/** Any rescue-style cutscene ID in this set will fail CI (req 14.5). */ +export const BANNED_RESCUE_SEQUENCE: ReadonlyArray = Object.freeze([ + 'rope_cut_rescue', + 'princess_rescued', + 'chapter_end_rescue', +]); + +export interface ISettlementStats { + totalScore: number; + stageScore: number; + comboCount: number; + flawless: boolean; + remainingTimeSec: number; +} + +export interface ISettlementResult { + stats: ISettlementStats; + closingLine: string; +} + +export class ChapterSettlement { + private stats: ISettlementStats; + + constructor(initialStats: ISettlementStats) { + this.stats = { ...initialStats }; + } + + public addScore(pts: number): void { + this.stats.stageScore += pts; + this.stats.totalScore += pts; + } + + public registerCombo(): void { + this.stats.comboCount++; + } + + public markTaken(): void { + this.stats.flawless = false; + } + + public setRemainingTime(sec: number): void { + this.stats.remainingTimeSec = sec; + } + + public assertCutsceneAllowed(id: CutsceneId | string): void { + if (BANNED_RESCUE_SEQUENCE.includes(id)) { + throw new Error( + `ChapterSettlement: cutscene "${id}" is explicitly banned — chapter 1 must end with the princess taken (req 14.5)` + ); + } + } + + public build(): ISettlementResult { + const closing = '公主被带走,续章待续…'; + return { stats: { ...this.stats }, closingLine: closing }; + } +} diff --git a/assets/scripts/logic/ChapterSettlement.ts.meta b/assets/scripts/logic/ChapterSettlement.ts.meta new file mode 100644 index 0000000..a673c20 --- /dev/null +++ b/assets/scripts/logic/ChapterSettlement.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "817d0864-8a09-49b5-bdcf-2dd3e74c26de", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/DamageSystem.ts b/assets/scripts/logic/DamageSystem.ts new file mode 100644 index 0000000..1666e6a --- /dev/null +++ b/assets/scripts/logic/DamageSystem.ts @@ -0,0 +1,51 @@ +import { AttackType } from '../data/Interfaces'; +import { PlayerStateMachine, DamageOutcome } from './PlayerStateMachine'; + +/** + * DamageSystem — the single funnel through which **every** player-facing + * damage event must flow (req 10.1-10.6). + * + * Decision precedence (req 10.3): + * 1. i-frames → no effect + * 2. sword parry → no effect (only vs. shuriken/sword; see PSM) + * 3. attack type × distance → dispatch to PlayerStateMachine.takeHit + * + * Distance thresholds: + * - fireball: lethal within 100px (req 10.4) + * - smoke bomb: lethal within 80px (req 10.5) + * - shuriken / sword: any contact is eligible + * + * Enemy-facing damage is a separate, simpler `applyToEnemy` helper. + */ + +export interface IDamageContext { + attackType: AttackType; + attackerX: number; + attackerY: number; + victimX: number; + victimY: number; +} + +export const FIREBALL_KILL_RADIUS = 100; +export const SMOKE_KILL_RADIUS = 80; + +export class DamageSystem { + constructor(private readonly psm: PlayerStateMachine) {} + + /** Try to damage the player. Returns `null` if the attack missed by distance. */ + public applyToPlayer(ctx: IDamageContext): DamageOutcome | null { + const distance = Math.hypot(ctx.attackerX - ctx.victimX, ctx.attackerY - ctx.victimY); + if (ctx.attackType === 'fireball' && distance > FIREBALL_KILL_RADIUS) return null; + if (ctx.attackType === 'smoke_bomb' && distance > SMOKE_KILL_RADIUS) return null; + // shuriken / sword rely on caller-side hitbox; reaching here means "hit". + return this.psm.takeHit(ctx.attackType); + } + + /** + * Apply flat damage to an enemy HP bucket. The damage number comes from + * the active weapon config. Returns the remaining HP (0 means killed). + */ + public applyToEnemy(currentHp: number, damage: number): number { + return Math.max(0, currentHp - damage); + } +} diff --git a/assets/scripts/logic/DamageSystem.ts.meta b/assets/scripts/logic/DamageSystem.ts.meta new file mode 100644 index 0000000..79e48bb --- /dev/null +++ b/assets/scripts/logic/DamageSystem.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "36078a13-9596-4b92-9236-b207a585035a", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/DropSystem.ts b/assets/scripts/logic/DropSystem.ts new file mode 100644 index 0000000..118207b --- /dev/null +++ b/assets/scripts/logic/DropSystem.ts @@ -0,0 +1,89 @@ +import { EnemyType, ItemType } from '../data/Interfaces'; + +/** + * Drop system (req 7.1-7.6). + * + * Rules encoded here: + * + * 1. **水晶玉 (crystal jade)** — deterministic: every 12th kill on forest + * stages spawns one above the player (req 7.1). Lifetime 13-20s (req 7.2, + * handled by caller). + * 2. **点丸 / 术丸** — after 3 consecutive 赤忍 kills, 50% chance of one or + * the other (req 7.3). + * 3. **魔笛** — dropped by 黑忍 on death (implemented in `HeiRenAI`). Picking + * it up triggers a screen-wipe (req 7.4), applied by the level manager. + * 4. **增丸** — fixed spawn point per level config, no probability (req 7.5). + * + * All probabilistic decisions funnel through an injectable `random()` so + * deterministic tests stay stable. + */ + +export interface IDropSystemCfg { + /** Kills required before a crystal jade is guaranteed. Default 12 (req 7.1). */ + crystalJadeEveryN?: number; + /** Kills of Chi Ren required before point/spell ball eligible. Default 3. */ + dianShuWanThresholdKills?: number; + /** Probability for dian_wan or shu_wan drop once threshold reached. Default 0.5. */ + dianShuWanProbability?: number; + /** Injectable RNG. Default `Math.random`. */ + random?: () => number; +} + +export interface IDropEvent { + item: ItemType; + x: number; + y: number; +} + +export class DropSystem { + private globalKills = 0; + private chiRenConsecutiveKills = 0; + private readonly crystalJadeEveryN: number; + private readonly dianShuWanThresholdKills: number; + private readonly dianShuWanProbability: number; + private readonly random: () => number; + + constructor(cfg: IDropSystemCfg = {}) { + this.crystalJadeEveryN = cfg.crystalJadeEveryN ?? 12; + this.dianShuWanThresholdKills = cfg.dianShuWanThresholdKills ?? 3; + this.dianShuWanProbability = cfg.dianShuWanProbability ?? 0.5; + this.random = cfg.random ?? Math.random; + } + + /** Register an enemy kill and return any drops produced. */ + public onEnemyKilled(enemy: EnemyType, at: { x: number; y: number }): IDropEvent[] { + this.globalKills++; + const drops: IDropEvent[] = []; + // Crystal-jade rule (deterministic, req 7.1). + if (this.globalKills % this.crystalJadeEveryN === 0) { + drops.push({ item: ItemType.CrystalJade, x: at.x, y: at.y + 180 }); + } + // Chi Ren consecutive rule (req 7.3). + if (enemy === EnemyType.ChiRen) { + this.chiRenConsecutiveKills++; + if (this.chiRenConsecutiveKills >= this.dianShuWanThresholdKills) { + this.chiRenConsecutiveKills = 0; + if (this.random() < this.dianShuWanProbability) { + const pickDian = this.random() < 0.5; + drops.push({ + item: pickDian ? ItemType.DianWan : ItemType.ShuWan, + x: at.x, + y: at.y, + }); + } + } + } else { + this.chiRenConsecutiveKills = 0; + } + return drops; + } + + public reset(): void { + this.globalKills = 0; + this.chiRenConsecutiveKills = 0; + } + + public get kills(): number { + return this.globalKills; + } +} diff --git a/assets/scripts/logic/DropSystem.ts.meta b/assets/scripts/logic/DropSystem.ts.meta new file mode 100644 index 0000000..e714bf6 --- /dev/null +++ b/assets/scripts/logic/DropSystem.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "0027692e-e7b0-4146-a401-25842bc5d1c0", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/EnemyAI.ts b/assets/scripts/logic/EnemyAI.ts new file mode 100644 index 0000000..828b835 --- /dev/null +++ b/assets/scripts/logic/EnemyAI.ts @@ -0,0 +1,233 @@ +import { AttackType, EnemyType, IEnemyConfig } from '../data/Interfaces'; + +/** + * Enemy AI base class + four concrete subclasses (req 6.1-6.7). + * + * Each enemy is modelled as a tiny state machine that ticks on `update(dt)` + * and emits an `IEnemyAction[]` the level manager then spawns into the world + * (bullets / smoke bombs / fireballs / sword swings). + * + * Enemies outside the camera's culling rect can be frozen by simply skipping + * `update()` — see `EnemyManager.update()` below (requirement 6.7 / 18.5). + */ + +export interface IEnemyAction { + kind: 'fire_bullet' | 'melee_swing' | 'spawn_item' | 'drop_item'; + attackType?: AttackType; + /** Origin of the projectile, world coords. */ + originX?: number; + originY?: number; + /** Velocity of the projectile. */ + velX?: number; + velY?: number; + /** For drop_item only — item id + world coords. */ + itemId?: string; +} + +export interface IPlayerSense { + x: number; + y: number; + isGrounded: boolean; +} + +export interface IEnemyAABB { + x: number; + y: number; + w: number; + h: number; +} + +export interface IEnemyUpdateCtx { + dtSec: number; + nowMs: number; + player: IPlayerSense; +} + +export abstract class EnemyAIBase { + public readonly type: EnemyType; + public pos: { x: number; y: number }; + public alive = true; + protected cooldownSec = 0; + protected readonly cfg: IEnemyConfig; + + constructor(cfg: IEnemyConfig, spawnX: number, spawnY: number) { + this.cfg = cfg; + this.type = cfg.id; + this.pos = { x: spawnX, y: spawnY }; + } + + public abstract update(ctx: IEnemyUpdateCtx): IEnemyAction[]; + + public get aabb(): IEnemyAABB { + return { x: this.pos.x, y: this.pos.y, w: this.cfg.size.w, h: this.cfg.size.h }; + } + + protected decrementCooldown(dtSec: number): boolean { + this.cooldownSec -= dtSec; + if (this.cooldownSec > 0) return false; + this.cooldownSec = this.cfg.attackIntervalSec; + return true; + } +} + +// ---------- Qing Ren (req 6.1) -------------------------------------------- +export class QingRenAI extends EnemyAIBase { + public update(ctx: IEnemyUpdateCtx): IEnemyAction[] { + if (!this.decrementCooldown(ctx.dtSec)) return []; + const dx = ctx.player.x - this.pos.x; + const horizontalDistance = Math.abs(dx); + const direction = dx >= 0 ? 1 : -1; + if (horizontalDistance < 64) { + return [{ kind: 'melee_swing', attackType: 'sword' }]; + } + return [ + { + kind: 'fire_bullet', + attackType: 'shuriken', + originX: this.pos.x, + originY: this.pos.y, + velX: 240 * direction, + velY: 0, + }, + ]; + } +} + +// ---------- Chi Ren (req 6.2-6.4) ----------------------------------------- +export class ChiRenAI extends EnemyAIBase { + private interceptCooldown = 0; + + public update(ctx: IEnemyUpdateCtx): IEnemyAction[] { + // Patrol horizontally toward the player at 120px/s (req 6.2). + const dx = ctx.player.x - this.pos.x; + const direction = dx >= 0 ? 1 : -1; + this.pos.x += direction * this.cfg.moveSpeed * ctx.dtSec; + + // Proactive intercept jump when player stands still within vision (req 6.3). + if (this.interceptCooldown > 0) this.interceptCooldown -= ctx.dtSec; + const playerIdle = Math.abs(ctx.player.x - this.pos.x) < 200 && ctx.player.isGrounded; + if (playerIdle && this.interceptCooldown <= 0) { + this.interceptCooldown = 3; + // Intercept arc: +X velocity + upward bounce, treated as position warp. + this.pos.y += 48; + } + + if (!this.decrementCooldown(ctx.dtSec)) return []; + return [ + { + kind: 'fire_bullet', + attackType: 'smoke_bomb', + originX: this.pos.x, + originY: this.pos.y, + velX: 140 * direction, + velY: 0, + }, + ]; + } +} + +// ---------- Hei Ren (req 6.5) --------------------------------------------- +export class HeiRenAI extends EnemyAIBase { + private hasDroppedMagicFlute = false; + + public update(ctx: IEnemyUpdateCtx): IEnemyAction[] { + if (!this.decrementCooldown(ctx.dtSec)) return []; + const dx = ctx.player.x - this.pos.x; + const direction = dx >= 0 ? 1 : -1; + if (Math.abs(dx) < 96) { + return [{ kind: 'melee_swing', attackType: 'sword' }]; + } + return [ + { + kind: 'fire_bullet', + attackType: 'shuriken', + originX: this.pos.x, + originY: this.pos.y, + velX: 200 * direction, + velY: 0, + }, + ]; + } + + /** Called by EnemyManager on death — yields at most one magic flute. */ + public onKilled(): IEnemyAction[] { + if (this.hasDroppedMagicFlute) return []; + this.hasDroppedMagicFlute = true; + return [{ kind: 'drop_item', itemId: 'mo_di', originX: this.pos.x, originY: this.pos.y }]; + } +} + +// ---------- Yao Fang (req 6.6) -------------------------------------------- +export class YaoFangAI extends EnemyAIBase { + public update(ctx: IEnemyUpdateCtx): IEnemyAction[] { + if (!this.decrementCooldown(ctx.dtSec)) return []; + const dx = ctx.player.x - this.pos.x; + const direction = dx >= 0 ? 1 : -1; + return [ + { + kind: 'fire_bullet', + attackType: 'fireball', + originX: this.pos.x, + originY: this.pos.y, + velX: 260 * direction, + velY: 0, + }, + ]; + } +} + +// ---------- Manager with camera-culling (req 6.7) ------------------------- + +export interface ICullingRect { + leftX: number; + rightX: number; + topY: number; + bottomY: number; +} + +export class EnemyManager { + private readonly enemies: EnemyAIBase[] = []; + + public spawn(enemy: EnemyAIBase): void { + this.enemies.push(enemy); + } + + public get all(): ReadonlyArray { + return this.enemies; + } + + /** + * Update all live enemies that intersect `cull`. Returns the concatenated + * list of actions emitted so the caller (LevelMgr) can instantiate + * projectiles, drops, etc. + */ + public update(dtSec: number, nowMs: number, player: IPlayerSense, cull: ICullingRect): IEnemyAction[] { + const actions: IEnemyAction[] = []; + for (const e of this.enemies) { + if (!e.alive) continue; + if (!this.inside(e, cull)) continue; + const ctx: IEnemyUpdateCtx = { dtSec, nowMs, player }; + actions.push(...e.update(ctx)); + } + return actions; + } + + public kill(enemy: EnemyAIBase): IEnemyAction[] { + enemy.alive = false; + if (enemy instanceof HeiRenAI) return enemy.onKilled(); + return []; + } + + public clear(): void { + this.enemies.length = 0; + } + + private inside(e: EnemyAIBase, cull: ICullingRect): boolean { + return ( + e.pos.x + e.aabb.w / 2 >= cull.leftX && + e.pos.x - e.aabb.w / 2 <= cull.rightX && + e.pos.y + e.aabb.h / 2 >= cull.bottomY && + e.pos.y - e.aabb.h / 2 <= cull.topY + ); + } +} diff --git a/assets/scripts/logic/EnemyAI.ts.meta b/assets/scripts/logic/EnemyAI.ts.meta new file mode 100644 index 0000000..76e57a4 --- /dev/null +++ b/assets/scripts/logic/EnemyAI.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "2a16b768-32a1-48f3-8456-7f63c6ac109d", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/JumpController.ts b/assets/scripts/logic/JumpController.ts new file mode 100644 index 0000000..f786a0a --- /dev/null +++ b/assets/scripts/logic/JumpController.ts @@ -0,0 +1,143 @@ +import { PlayerMotionModel, DEFAULT_GRAVITY } from './PlayerMotionModel'; +import { + JUMP_HEIGHT_STANDARD, + JUMP_HEIGHT_CHARGED, + JUMP_HEIGHT_YELLOW, + JUMP_PREPARE_DELAY_MS, + JUMP_CHARGE_THRESHOLD_MS, + PlayerColorState, +} from '../common/Constants'; +import { JoystickAngleClass } from '../ui/InputModel'; + +/** + * Jump controller — orchestrates the jump lifecycle on top of + * `PlayerMotionModel` (task 4.2). + * + * Lifecycle (ms timestamps supplied by caller so Jest can stay deterministic): + * + * pressJump(ts) + * ├─ not grounded? ignore (req 2.4) + * ├─ enter `charging` state, start timer + * └─ emit `jump_prepare_start` + * + * releaseJump(ts, direction) + * ├─ ts - pressTs >= JUMP_CHARGE_THRESHOLD_MS → charged high-jump (req 2.3) + * ├─ else → standard jump (req 2.2) + * ├─ +150ms crouch delay before launch (req 2.8) + * └─ parabolic_right / parabolic_left → horizontal impulse too (req 2.5) + * + * Yellow-state uses a taller vertical impulse (req 2.2). + */ + +export type JumpPhase = 'idle' | 'charging' | 'crouching' | 'launched'; + +export interface IJumpDispatchResult { + phase: JumpPhase; + height: number; + horizontalImpulse: number; + reason?: string; +} + +/** How much horizontal velocity a parabolic jump imparts (px/s). */ +export const PARABOLIC_HORIZONTAL_SPEED = 180; + +/** + * Converts `verticalTravel (px)` to the initial velocity needed to reach it. + * Using `v = sqrt(2 * g * h)` under constant gravity. + */ +export function heightToImpulse(heightPx: number, gravity: number = DEFAULT_GRAVITY): number { + return Math.sqrt(2 * gravity * heightPx); +} + +export class JumpController { + private phase: JumpPhase = 'idle'; + private pressTs = 0; + private crouchEndsAt = 0; + private pendingImpulse: { vy: number; vx: number } | null = null; + + constructor( + private readonly motion: PlayerMotionModel, + private readonly prepareDelayMs: number = JUMP_PREPARE_DELAY_MS, + private readonly chargeThresholdMs: number = JUMP_CHARGE_THRESHOLD_MS + ) {} + + /** Called each frame with `now` from `TimeMgr.realTime * 1000`. */ + public tick(nowMs: number): void { + if (this.phase === 'crouching' && nowMs >= this.crouchEndsAt) { + if (this.pendingImpulse) { + this.motion.applyJumpImpulse(this.pendingImpulse.vy); + this.motion.applyHorizontalImpulse(this.pendingImpulse.vx); + this.pendingImpulse = null; + } + this.phase = 'launched'; + } + // Once the motion model reports grounded again, reset to idle. + if (this.phase === 'launched' && this.motion.isGrounded) { + this.phase = 'idle'; + } + } + + /** Called on `jumpPressed` UI event. */ + public pressJump(nowMs: number): IJumpDispatchResult { + if (!this.motion.isGrounded) { + return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'airborne' }; + } + if (this.phase !== 'idle') { + return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'already_in_jump' }; + } + this.phase = 'charging'; + this.pressTs = nowMs; + return { phase: this.phase, height: 0, horizontalImpulse: 0 }; + } + + /** Called on `jumpReleased` UI event. */ + public releaseJump( + nowMs: number, + joystickClass: JoystickAngleClass, + colorState: PlayerColorState = PlayerColorState.Red + ): IJumpDispatchResult { + if (this.phase !== 'charging') { + return { phase: this.phase, height: 0, horizontalImpulse: 0, reason: 'not_charging' }; + } + const heldMs = nowMs - this.pressTs; + const charged = heldMs >= this.chargeThresholdMs; + + let height = charged + ? JUMP_HEIGHT_CHARGED + : colorState === PlayerColorState.Yellow + ? JUMP_HEIGHT_YELLOW + : JUMP_HEIGHT_STANDARD; + + let vx = 0; + if (joystickClass === 'parabolic_right') { + vx = PARABOLIC_HORIZONTAL_SPEED; + } else if (joystickClass === 'parabolic_left') { + vx = -PARABOLIC_HORIZONTAL_SPEED; + } + + this.phase = 'crouching'; + this.crouchEndsAt = nowMs + this.prepareDelayMs; + const vy = heightToImpulse(height); + this.pendingImpulse = { vy, vx }; + return { phase: this.phase, height, horizontalImpulse: vx }; + } + + /** Cancel any pending jump (used on pause / scene unload). */ + public cancel(): void { + this.phase = 'idle'; + this.pendingImpulse = null; + } + + /** Expose the current jump phase for HUD feedback (disabled button, etc.). */ + public getPhase(): JumpPhase { + return this.phase; + } + + /** + * Whether the UI should render the jump button as enabled. Disabled when + * airborne or mid-cycle (req 2.4). + */ + public isButtonEnabled(): boolean { + return this.motion.isGrounded && this.phase === 'idle'; + } +} diff --git a/assets/scripts/logic/JumpController.ts.meta b/assets/scripts/logic/JumpController.ts.meta new file mode 100644 index 0000000..e7b792d --- /dev/null +++ b/assets/scripts/logic/JumpController.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "449ab620-ad69-4b91-9043-c588ec6182f3", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/LevelMgr.ts b/assets/scripts/logic/LevelMgr.ts new file mode 100644 index 0000000..77ec750 --- /dev/null +++ b/assets/scripts/logic/LevelMgr.ts @@ -0,0 +1,107 @@ +import { EnemyType, ILevelConfig, ILevelObjective } from '../data/Interfaces'; + +/** + * Level lifecycle manager (task 7.1 + 7.2). + * + * Given an `ILevelConfig`, this class: + * - ticks the time-limit countdown (req 8.1-8.5) + * - tracks kill counters by enemy type + * - evaluates the level objective each tick + * - emits a structured `LevelResult` when the level ends + * + * Scene + parallax rendering live in the Cocos view layer; this module is + * intentionally engine-agnostic so the whole progression logic is Jest- + * testable. + */ + +export type LevelStatus = 'running' | 'victory' | 'timeout' | 'player_dead'; + +export interface ILevelResult { + status: LevelStatus; + elapsedSec: number; + kills: Record; + remainingSec: number; +} + +export class LevelMgr { + private elapsedSec = 0; + private kills = new Map(); + private totalKills = 0; + private bossKilled = false; + private reachedTop = false; + private playerDead = false; + public readonly level: ILevelConfig; + + constructor(level: ILevelConfig) { + this.level = level; + } + + public tick(dtSec: number): LevelStatus { + if (this.isTerminal()) return this.currentStatus(); + this.elapsedSec += dtSec; + return this.currentStatus(); + } + + public onEnemyKilled(enemy: EnemyType): void { + this.kills.set(enemy, (this.kills.get(enemy) ?? 0) + 1); + this.totalKills++; + } + + public onBossKilled(): void { + this.bossKilled = true; + } + + public onReachedTop(): void { + this.reachedTop = true; + } + + public onPlayerDied(): void { + this.playerDead = true; + } + + public result(): ILevelResult { + return { + status: this.currentStatus(), + elapsedSec: this.elapsedSec, + kills: this.killsAsObject(), + remainingSec: Math.max(0, this.level.timeLimitSec - this.elapsedSec), + }; + } + + public get totalKillsCount(): number { + return this.totalKills; + } + + // ------------------------------------------------------------------ + + private currentStatus(): LevelStatus { + if (this.playerDead) return 'player_dead'; + if (this.evaluateObjective(this.level.objective)) return 'victory'; + if (this.elapsedSec >= this.level.timeLimitSec) return 'timeout'; + return 'running'; + } + + private isTerminal(): boolean { + const s = this.currentStatus(); + return s !== 'running'; + } + + private evaluateObjective(o: ILevelObjective): boolean { + if (o.kind === 'kill_count' && o.enemy && o.count) { + return (this.kills.get(o.enemy) ?? 0) >= o.count; + } + if (o.kind === 'reach_top') { + return this.reachedTop; + } + if (o.kind === 'defeat_boss') { + return this.bossKilled; + } + return false; + } + + private killsAsObject(): Record { + const out: Record = {}; + for (const [k, v] of this.kills.entries()) out[k] = v; + return out; + } +} diff --git a/assets/scripts/logic/LevelMgr.ts.meta b/assets/scripts/logic/LevelMgr.ts.meta new file mode 100644 index 0000000..5583bae --- /dev/null +++ b/assets/scripts/logic/LevelMgr.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "97238e1a-49db-41c8-9d3f-60a510388814", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/PlayerMotionModel.ts b/assets/scripts/logic/PlayerMotionModel.ts new file mode 100644 index 0000000..a630e49 --- /dev/null +++ b/assets/scripts/logic/PlayerMotionModel.ts @@ -0,0 +1,166 @@ +import { MOVE_SPEED, PlayerColorState } from '../common/Constants'; + +/** + * Pure-TS motion model for the player character. + * + * This is the foundation used by tasks 4.1, 4.2 (jumping/parabolic) and + * later 5.x (combo attacks). It is deliberately engine-free so that the + * entire movement state-machine is Jest-testable (requirement 2.1, 5.1-5.2). + * + * Coordinate convention: landscape design resolution, **+y is up**. All + * numbers are in design pixels / seconds. + */ + +export interface IAxisAlignedBox { + /** Centre x. */ + x: number; + /** Centre y. */ + y: number; + /** Full width. */ + w: number; + /** Full height. */ + h: number; +} + +/** A simple horizontal platform the player may stand on. */ +export interface IPlatform { + /** Platform top edge y (world px). */ + topY: number; + /** Platform left edge x (world px). */ + leftX: number; + /** Platform right edge x (world px). */ + rightX: number; +} + +/** Horizontal input reported by `InputModel` / `FloatingControlLayer`. */ +export type HorizontalInput = -1 | 0 | 1; + +export interface IPlayerMotionOptions { + /** World gravity (px/s²). Default derived so a 250-px jump lasts ~0.45 s. */ + gravity?: number; + /** Starting AABB of the player. */ + aabb: IAxisAlignedBox; + /** Platforms defining the walkable terrain. Can be swapped per-level. */ + platforms: IPlatform[]; + /** Starting color state. */ + initialColorState?: PlayerColorState; +} + +export const DEFAULT_GRAVITY = 2500; // px/s² — tuned so a 250px jump peaks in ~0.45s + +/** + * Encapsulates player horizontal/vertical movement + ground detection. + * Call `setHorizontalInput()` + `requestJump()` every frame from the view + * layer, then invoke `update(dt)` to advance the simulation. + */ +export class PlayerMotionModel { + // -- mutable state ------------------------------------------------------ + private _vx = 0; + private _vy = 0; + private _grounded = false; + private _colorState: PlayerColorState; + private _horizontalInput: HorizontalInput = 0; + private _aabb: IAxisAlignedBox; + private _platforms: IPlatform[]; + private readonly gravity: number; + + constructor(options: IPlayerMotionOptions) { + this._aabb = { ...options.aabb }; + this._platforms = options.platforms.slice(); + this._colorState = options.initialColorState ?? PlayerColorState.Red; + this.gravity = options.gravity ?? DEFAULT_GRAVITY; + } + + // -- accessors ---------------------------------------------------------- + public get aabb(): IAxisAlignedBox { + return this._aabb; + } + public get vx(): number { + return this._vx; + } + public get vy(): number { + return this._vy; + } + public get isGrounded(): boolean { + return this._grounded; + } + public get colorState(): PlayerColorState { + return this._colorState; + } + + // -- inputs ------------------------------------------------------------- + + /** -1 moves left, 1 moves right, 0 stops (req 2.1). */ + public setHorizontalInput(input: HorizontalInput): void { + this._horizontalInput = input; + } + + /** + * Update the player's color state (e.g. after a crystal-jade pickup). + * Movement speed will immediately reflect the new bucket (req 5.1-5.2). + */ + public setColorState(state: PlayerColorState): void { + this._colorState = state; + } + + /** Impulse-based vertical jump. Does nothing if not grounded (req 2.4). */ + public applyJumpImpulse(verticalPxPerSec: number): boolean { + if (!this._grounded) return false; + this._vy = verticalPxPerSec; + this._grounded = false; + return true; + } + + /** Additional horizontal impulse used by parabolic jumps (req 2.5). */ + public applyHorizontalImpulse(vx: number): void { + this._vx = vx; + } + + /** Swap level terrain; also clears grounded so we re-settle on next update. */ + public setPlatforms(platforms: IPlatform[]): void { + this._platforms = platforms.slice(); + this._grounded = false; + } + + // -- simulation step ---------------------------------------------------- + + /** + * Advance the simulation by `dt` seconds. In hardcore mode (req 13.4) the + * horizontal velocity is **only** rewritten from input when on the + * ground; mid-air `_vx` is preserved (起跳定型). + */ + public update(dt: number): void { + if (this._grounded) { + this._vx = this._horizontalInput * MOVE_SPEED[this._colorState]; + this._vy = 0; + } else { + // Apply gravity (requirement 13.4: no air-control). + this._vy -= this.gravity * dt; + } + // Integrate position. + this._aabb = { + ...this._aabb, + x: this._aabb.x + this._vx * dt, + y: this._aabb.y + this._vy * dt, + }; + // Resolve against platforms (basic AABB vs. top-surface only). + this._grounded = false; + for (const p of this._platforms) { + if (this.isRestingOn(p)) { + this._grounded = true; + this._aabb = { ...this._aabb, y: p.topY + this._aabb.h / 2 }; + if (this._vy < 0) this._vy = 0; + break; + } + } + } + + // -- helpers ------------------------------------------------------------ + + private isRestingOn(p: IPlatform): boolean { + const feetY = this._aabb.y - this._aabb.h / 2; + const withinHorizontal = this._aabb.x >= p.leftX && this._aabb.x <= p.rightX; + const atOrJustBelowTop = feetY <= p.topY + 0.5 && feetY >= p.topY - 6 && this._vy <= 0; + return withinHorizontal && atOrJustBelowTop; + } +} diff --git a/assets/scripts/logic/PlayerMotionModel.ts.meta b/assets/scripts/logic/PlayerMotionModel.ts.meta new file mode 100644 index 0000000..9c0abac --- /dev/null +++ b/assets/scripts/logic/PlayerMotionModel.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "37159f2f-c9e3-4a3c-a735-caa7476b0266", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/PlayerStateMachine.ts b/assets/scripts/logic/PlayerStateMachine.ts new file mode 100644 index 0000000..b3564e2 --- /dev/null +++ b/assets/scripts/logic/PlayerStateMachine.ts @@ -0,0 +1,145 @@ +import { PlayerColorState, PLAYER_IFRAME_SECONDS } from '../common/Constants'; +import { AttackType } from '../data/Interfaces'; + +/** + * Player color-state machine (req 5.1-5.6) + parry (req 3.7-3.8) + i-frames + * (req 10.2-10.3). + * + * Transitions: + * + * [Red] --crystal_jade--> [Green] --crystal_jade--> [Yellow] + * ^ | | + * |---- shuriken|sword --|---- shuriken|sword -----| + * | | + * ---- fireball / smoke_bomb -----------------> ⚰️ dead + * ---- shuriken|sword while red ----------------> ⚰️ dead + * + * The machine is deliberately engine-agnostic so combat logic can be unit- + * tested on either the web or inside a future dedicated simulator. + */ + +export type DamageOutcome = + | { kind: 'no_effect'; reason: 'iframe' | 'parried' } + | { kind: 'downgraded'; from: PlayerColorState; to: PlayerColorState } + | { kind: 'died'; cause: AttackType }; + +export interface IPlayerState { + color: PlayerColorState; + lives: number; + /** Whether the ninja sword is currently in its parry-active frame window. */ + swordActive: boolean; + /** Remaining i-frame time (seconds). */ + iframeSec: number; + isDead: boolean; +} + +export class PlayerStateMachine { + private state: IPlayerState; + + constructor(initialLives = 3) { + this.state = { + color: PlayerColorState.Red, + lives: initialLives, + swordActive: false, + iframeSec: 0, + isDead: false, + }; + } + + public get snapshot(): IPlayerState { + return { ...this.state }; + } + + public get color(): PlayerColorState { + return this.state.color; + } + + public get lives(): number { + return this.state.lives; + } + + public get isDead(): boolean { + return this.state.isDead; + } + + // ---- external events -------------------------------------------------- + + /** Player picked up a crystal jade (req 5.1-5.2). */ + public pickupCrystalJade(): PlayerColorState { + if (this.state.color === PlayerColorState.Red) { + this.state.color = PlayerColorState.Green; + } else if (this.state.color === PlayerColorState.Green) { + this.state.color = PlayerColorState.Yellow; + } + return this.state.color; + } + + /** Player picked up an 增丸 — permanently +1 life (req 7.5). */ + public pickupZengWan(): number { + this.state.lives += 1; + return this.state.lives; + } + + /** + * Toggle sword active window. Called by `AttackController.tick()` for the + * duration of a sword swing so the parry window (req 3.7-3.8) stays tight. + */ + public setSwordActive(active: boolean): void { + this.state.swordActive = active; + } + + /** Advance i-frames on every physics tick. */ + public tick(dtSec: number): void { + if (this.state.iframeSec > 0) { + this.state.iframeSec = Math.max(0, this.state.iframeSec - dtSec); + } + } + + /** + * Apply incoming damage of `attackType`. Returns the resulting outcome so + * the caller can render the appropriate FX/HUD change. + */ + public takeHit(attackType: AttackType): DamageOutcome { + if (this.state.iframeSec > 0) { + return { kind: 'no_effect', reason: 'iframe' }; + } + // Sword-active parry applies to shuriken & sword only (req 3.7-3.8). + if (this.state.swordActive && (attackType === 'shuriken' || attackType === 'sword')) { + this.startIFrames(); + return { kind: 'no_effect', reason: 'parried' }; + } + // Fireball / smoke bomb are always lethal (req 5.5, 10.4-10.5). + if (attackType === 'fireball' || attackType === 'smoke_bomb') { + return this.consumeLife(attackType); + } + // Ordinary shuriken / sword damage: downgrade by one tier or die. + if (this.state.color === PlayerColorState.Yellow) { + this.state.color = PlayerColorState.Red; + this.startIFrames(); + return { kind: 'downgraded', from: PlayerColorState.Yellow, to: PlayerColorState.Red }; + } + if (this.state.color === PlayerColorState.Green) { + this.state.color = PlayerColorState.Red; + this.startIFrames(); + return { kind: 'downgraded', from: PlayerColorState.Green, to: PlayerColorState.Red }; + } + // Red → dead + return this.consumeLife(attackType); + } + + // ---- helpers ---------------------------------------------------------- + + private consumeLife(cause: AttackType): DamageOutcome { + this.state.lives = Math.max(0, this.state.lives - 1); + this.state.color = PlayerColorState.Red; + this.startIFrames(); + if (this.state.lives === 0) { + this.state.isDead = true; + } + return { kind: 'died', cause }; + } + + private startIFrames(): void { + this.state.iframeSec = PLAYER_IFRAME_SECONDS; + } +} diff --git a/assets/scripts/logic/PlayerStateMachine.ts.meta b/assets/scripts/logic/PlayerStateMachine.ts.meta new file mode 100644 index 0000000..31cb423 --- /dev/null +++ b/assets/scripts/logic/PlayerStateMachine.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "f13127c9-f546-4df3-8e63-3a2e7d4fa23c", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/ScoreSystem.ts b/assets/scripts/logic/ScoreSystem.ts new file mode 100644 index 0000000..683582c --- /dev/null +++ b/assets/scripts/logic/ScoreSystem.ts @@ -0,0 +1,105 @@ +import { WeaponType } from '../data/Interfaces'; + +/** + * Score system (task 9.3, req 12.1-12.8). + * + * Scoring table: + * - Ninja sword kill ×2.0 base + * - Shuriken kill ×1.0 base + * - Perfect parry counterkill ×3.0 base + * - 5-combo "刃接触" bonus +1500 + * - Flawless level (no damage) ×3.0 total + * - Remaining time bonus +10 pts / remaining sec + * + * Everything is deterministic and `Math.random`-free so QA can reproduce + * every score calculation in unit tests. + */ + +export const BASE_ENEMY_SCORE = 100; +export const COMBO_BONUS = 1500; +export const COMBO_THRESHOLD = 5; +export const TIME_BONUS_PER_SEC = 10; + +export interface IScoreSnapshot { + baseScore: number; + comboBonus: number; + timeBonus: number; + flawlessMultiplier: number; + finalScore: number; + killCount: number; + comboCount: number; + consecutiveBladeHits: number; +} + +export class ScoreSystem { + private baseScore = 0; + private comboBonus = 0; + private killCount = 0; + private consecutiveBladeHits = 0; + private comboCount = 0; + private flawless = true; + private timeBonus = 0; + + public reset(): void { + this.baseScore = 0; + this.comboBonus = 0; + this.killCount = 0; + this.consecutiveBladeHits = 0; + this.comboCount = 0; + this.flawless = true; + this.timeBonus = 0; + } + + /** Record a kill weighted by weapon type (req 12.1-12.2). */ + public recordEnemyKill(weapon: WeaponType): void { + this.killCount++; + const multiplier = weapon === WeaponType.NinjaSword ? 2 : 1; + this.baseScore += BASE_ENEMY_SCORE * multiplier; + } + + /** Perfect parry followed by a counter-kill (req 12.3). */ + public recordParryKill(): void { + this.killCount++; + this.baseScore += BASE_ENEMY_SCORE * 3; + } + + /** Record a single blade contact; every 5 contacts award a combo bonus (req 12.4). */ + public recordBladeContact(): void { + this.consecutiveBladeHits++; + if (this.consecutiveBladeHits >= COMBO_THRESHOLD) { + this.comboCount++; + this.comboBonus += COMBO_BONUS; + this.consecutiveBladeHits = 0; + } + } + + /** Resets the consecutive blade counter if the combo is broken. */ + public breakBladeChain(): void { + this.consecutiveBladeHits = 0; + } + + /** Player took damage → flawless multiplier lost (req 12.5). */ + public markTaken(): void { + this.flawless = false; + } + + /** Stage-end timing bonus (req 12.6). */ + public setRemainingTimeBonus(remainingSec: number): void { + this.timeBonus = Math.max(0, Math.floor(remainingSec)) * TIME_BONUS_PER_SEC; + } + + public snapshot(): IScoreSnapshot { + const flawlessMultiplier = this.flawless ? 3 : 1; + const finalScore = (this.baseScore + this.comboBonus + this.timeBonus) * flawlessMultiplier; + return { + baseScore: this.baseScore, + comboBonus: this.comboBonus, + timeBonus: this.timeBonus, + flawlessMultiplier, + finalScore, + killCount: this.killCount, + comboCount: this.comboCount, + consecutiveBladeHits: this.consecutiveBladeHits, + }; + } +} diff --git a/assets/scripts/logic/ScoreSystem.ts.meta b/assets/scripts/logic/ScoreSystem.ts.meta new file mode 100644 index 0000000..c3f254c --- /dev/null +++ b/assets/scripts/logic/ScoreSystem.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "96dc60f4-e45f-426a-8832-c36a6662f45f", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/TutorialMgr.ts b/assets/scripts/logic/TutorialMgr.ts new file mode 100644 index 0000000..c0057a1 --- /dev/null +++ b/assets/scripts/logic/TutorialMgr.ts @@ -0,0 +1,118 @@ +import { StorageMgr, globalStorageMgr } from '../common/StorageMgr'; +import { STORAGE_KEY } from '../common/Constants'; + +/** + * Tutorial manager (req 11.1-11.5, task 9.3). + * + * Pre-defined tutorial sequences for levels 1-1, 1-2, 1-3. Each step has an + * ID the view layer uses to drive highlight-arrows; the step is "completed" + * when the player performs the action, which the view layer signals via + * `reportAction()`. + */ + +export interface ITutorialStep { + id: string; + /** Human-readable hint (displayed by the view layer). */ + hint: string; + /** Action id the player must perform to advance. */ + requiredAction: string; +} + +export interface ITutorialSequence { + levelId: string; + steps: ITutorialStep[]; +} + +/** Built-in tutorials for Chapter 1 (req 11.1-11.3). */ +export const BUILTIN_TUTORIALS: ITutorialSequence[] = [ + { + levelId: '1-1', + steps: [ + { id: 'attack', hint: '点击右下的手里剑按钮', requiredAction: 'fire_shuriken' }, + { id: 'joystick', hint: '拖动左下摇杆移动', requiredAction: 'move' }, + { id: 'jump', hint: '点击跳跃按钮', requiredAction: 'jump' }, + ], + }, + { + levelId: '1-2', + steps: [ + { id: 'parabolic', hint: '摇杆 45° 并跳跃 — 抛物线跳跃', requiredAction: 'parabolic_jump' }, + { id: 'exclusive', hint: '两个攻击按钮互斥,选一个用', requiredAction: 'attack_switch' }, + { id: 'parry', hint: '忍者刀可以格挡敌人刀剑', requiredAction: 'parry' }, + { id: 'combo', hint: '跳跃中同时攻击', requiredAction: 'jump_attack' }, + { id: 'auto_upgrade', hint: '拾取水晶玉自动强化', requiredAction: 'pickup_crystal' }, + ], + }, + { + levelId: '1-3', + steps: [ + { id: 'butterfly', hint: '先击中 BOSS 身旁的蝴蝶', requiredAction: 'hit_butterfly' }, + { id: 'boss_identify', hint: '识别 BOSS 攻击模式', requiredAction: 'dodge_boss_attack' }, + { id: 'one_shot', hint: '显形后一击必杀', requiredAction: 'hit_revealed_boss' }, + ], + }, +]; + +export class TutorialMgr { + private currentLevelId: string | null = null; + private currentStepIndex = 0; + + constructor( + private readonly storage: StorageMgr = globalStorageMgr, + private readonly sequences: ITutorialSequence[] = BUILTIN_TUTORIALS + ) {} + + /** Start the tutorial for `levelId` if not already completed. */ + public maybeStart(levelId: string): ITutorialStep | null { + if (this.isCompleted(levelId)) return null; + const seq = this.sequences.find((s) => s.levelId === levelId); + if (!seq) return null; + this.currentLevelId = levelId; + this.currentStepIndex = 0; + return seq.steps[0]; + } + + /** Called by gameplay whenever the player performs an action. */ + public reportAction(action: string): ITutorialStep | 'finished' | 'no_op' { + if (!this.currentLevelId) return 'no_op'; + const seq = this.sequences.find((s) => s.levelId === this.currentLevelId); + if (!seq) return 'no_op'; + const current = seq.steps[this.currentStepIndex]; + if (action !== current.requiredAction) return 'no_op'; + this.currentStepIndex++; + if (this.currentStepIndex >= seq.steps.length) { + this.markCompleted(this.currentLevelId); + this.currentLevelId = null; + this.currentStepIndex = 0; + return 'finished'; + } + return seq.steps[this.currentStepIndex]; + } + + public isCompleted(levelId: string): boolean { + const completed = this.storage.get(STORAGE_KEY.TutorialDone, []); + return completed.includes(levelId); + } + + public resetAll(): void { + this.storage.remove(STORAGE_KEY.TutorialDone); + this.currentLevelId = null; + this.currentStepIndex = 0; + } + + public get isActive(): boolean { + return this.currentLevelId !== null; + } + + public currentStep(): ITutorialStep | null { + if (!this.currentLevelId) return null; + const seq = this.sequences.find((s) => s.levelId === this.currentLevelId); + return seq?.steps[this.currentStepIndex] ?? null; + } + + private markCompleted(levelId: string): void { + const current = this.storage.get(STORAGE_KEY.TutorialDone, []); + if (!current.includes(levelId)) current.push(levelId); + this.storage.set(STORAGE_KEY.TutorialDone, current); + } +} diff --git a/assets/scripts/logic/TutorialMgr.ts.meta b/assets/scripts/logic/TutorialMgr.ts.meta new file mode 100644 index 0000000..1505425 --- /dev/null +++ b/assets/scripts/logic/TutorialMgr.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "e1bd54ae-07af-4ad3-8085-24c1a88bdce0", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/logic/index.ts b/assets/scripts/logic/index.ts new file mode 100644 index 0000000..60fcebe --- /dev/null +++ b/assets/scripts/logic/index.ts @@ -0,0 +1,17 @@ +/** + * Logic layer — game-state machines, AI, damage system, level framework. + */ + +export * from './PlayerMotionModel'; +export * from './JumpController'; +export * from './AttackController'; +export * from './PlayerStateMachine'; +export * from './EnemyAI'; +export * from './DropSystem'; +export * from './DamageSystem'; +export * from './CameraScroller'; +export * from './LevelMgr'; +export * from './BossController'; +export * from './ChapterSettlement'; +export * from './TutorialMgr'; +export * from './ScoreSystem'; diff --git a/assets/scripts/logic/index.ts.meta b/assets/scripts/logic/index.ts.meta new file mode 100644 index 0000000..e77c8f3 --- /dev/null +++ b/assets/scripts/logic/index.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "a0972eb0-c1b0-4e73-adff-5481e47d3b19", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/scene_entries.meta b/assets/scripts/scene_entries.meta new file mode 100644 index 0000000..fb5728d --- /dev/null +++ b/assets/scripts/scene_entries.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "38cdf707-78a1-4404-924c-12ba494e24a9", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/scene_entries/BossEntry.ts b/assets/scripts/scene_entries/BossEntry.ts new file mode 100644 index 0000000..7c4204f --- /dev/null +++ b/assets/scripts/scene_entries/BossEntry.ts @@ -0,0 +1,76 @@ +import { _decorator, Component, director } from 'cc'; +import { ConfigMgr } from '../data/ConfigMgr'; +import { CCJsonLoader } from './CCJsonLoader'; +import { BossController } from '../logic/BossController'; +import { ensureCanvasSize, createLabel, createButton } from './MainMenuEntry'; +import { Color } from 'cc'; +import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants'; + +const { ccclass, property } = _decorator; + +/** + * Boss scene entry (task 7.3 / 8.2 hookup, req 9.x + 14.x). + * + * Attach to the root node of `Boss_ShuangHuanFang.scene`. Default bossId + * matches the chapter-1 final boss. + * + * When `autoBuildUI` is enabled, two temporary debug buttons are placed on + * screen so the flow can be validated before the combat view layer lands: + * - Left side: "Hit Butterfly" → `onButterflyHit` + * - Right side: "Hit Body" → `onBodyHit` + * These will be removed once the real combat HUD is built. + */ +@ccclass('BossEntry') +export class BossEntry extends Component { + @property({ tooltip: '对应 configs/bosses.json 中的 id' }) + public bossId: string = 'shuang_huan_fang'; + + @property({ tooltip: '是否自动创建调试按钮 (战斗 HUD 就绪前使用)' }) + public autoBuildUI: boolean = true; + + private ctrl: BossController | undefined; + + protected async onLoad(): Promise { + if (this.autoBuildUI) this.buildDefaultUI(); + const cfg = new ConfigMgr(new CCJsonLoader()); + await cfg.load(); + this.ctrl = new BossController(cfg.boss(this.bossId)); + } + + /** Dev-hook: attack landed on the butterfly (req 9.2). */ + public onButterflyHit(): void { + const events = this.ctrl?.onButterflyHit() ?? []; + this.processEvents(events); + } + + /** Dev-hook: attack landed on boss body (req 9.3). */ + public onBodyHit(): void { + const events = this.ctrl?.onBodyHit() ?? []; + this.processEvents(events); + } + + private processEvents(events: ReturnType>): void { + for (const ev of events) { + if (ev.kind === 'boss_killed') { + director.loadScene('Settlement'); + return; + } + } + } + + private buildDefaultUI(): void { + ensureCanvasSize(this.node); + createLabel(this.node, 'BOSS · 双幻坊', 0, DESIGN_HEIGHT / 2 - 60, 32, Color.WHITE); + createLabel( + this.node, + '调试:先击中蝴蝶 → 再击中本体', + 0, + DESIGN_HEIGHT / 2 - 110, + 18, + new Color(200, 200, 200, 255), + ); + // Left / right debug buttons, 180px off center. + createButton(this.node, 'Hit Butterfly', -180, -120, 220, 70, () => this.onButterflyHit()); + createButton(this.node, 'Hit Body', 180, -120, 220, 70, () => this.onBodyHit()); + } +} diff --git a/assets/scripts/scene_entries/BossEntry.ts.meta b/assets/scripts/scene_entries/BossEntry.ts.meta new file mode 100644 index 0000000..eea3b8c --- /dev/null +++ b/assets/scripts/scene_entries/BossEntry.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "1ac5856f-147f-410c-8b8b-8de267952b40", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/scene_entries/CCJsonLoader.ts b/assets/scripts/scene_entries/CCJsonLoader.ts new file mode 100644 index 0000000..4eb97d2 --- /dev/null +++ b/assets/scripts/scene_entries/CCJsonLoader.ts @@ -0,0 +1,23 @@ +import { resources, JsonAsset } from 'cc'; +import { IJsonLoader } from '../data/ConfigMgr'; + +/** + * Cocos Creator backed JSON loader. Used by Scene Entry components to feed + * `ConfigMgr`. Production-only: unit tests inject `MapJsonLoader` instead. + * + * The path parameter matches what `ConfigMgr` requests, e.g. `configs/enemies`. + * Cocos resolves it against the `assets/resources/` root. + */ +export class CCJsonLoader implements IJsonLoader { + public load(path: string): Promise { + return new Promise((resolve, reject) => { + resources.load(path, JsonAsset, (err: Error | null, asset: unknown) => { + if (err || !asset) { + reject(err ?? new Error(`CCJsonLoader: asset not found at ${path}`)); + return; + } + resolve((asset as JsonAsset).json as T); + }); + }); + } +} diff --git a/assets/scripts/scene_entries/CCJsonLoader.ts.meta b/assets/scripts/scene_entries/CCJsonLoader.ts.meta new file mode 100644 index 0000000..4b31ef5 --- /dev/null +++ b/assets/scripts/scene_entries/CCJsonLoader.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "933a1f2f-13d2-4c18-9a1d-62abd39776b2", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/scene_entries/LevelEntry.ts b/assets/scripts/scene_entries/LevelEntry.ts new file mode 100644 index 0000000..316cb91 --- /dev/null +++ b/assets/scripts/scene_entries/LevelEntry.ts @@ -0,0 +1,84 @@ +import { _decorator, Component, director, Color, Label, Node } from 'cc'; +import { ConfigMgr } from '../data/ConfigMgr'; +import { CCJsonLoader } from './CCJsonLoader'; +import { LevelMgr } from '../logic/LevelMgr'; +import { ensureCanvasSize, createLabel } from './MainMenuEntry'; +import { DESIGN_HEIGHT } from '../common/Constants'; + +const { ccclass, property } = _decorator; + +/** + * Generic Level scene entry (task 7.1-7.2 hookup). + * + * Attach to the root node of `Level_1_1` … `Level_1_5`. Configure the + * `levelId` property in the Inspector ("1-1", "1-2", ...). + * + * For MVP the scene has no real gameplay rendering yet; `autoBuildUI` + * simply draws a top-centered label with the current level id so you can + * verify scene transitions by eye. + */ +@ccclass('LevelEntry') +export class LevelEntry extends Component { + @property({ tooltip: '本关的 levelId (与 configs/levels.json 对应),如 1-1 / 1-2 / 1-3 / 1-4 / 1-5' }) + public levelId: string = '1-1'; + + @property({ tooltip: '胜利后跳转的场景,留空则按 1-1 → 1-2 → ... → 1-5 → Boss 自动推导' }) + public nextSceneName: string = ''; + + @property({ tooltip: '是否自动创建关卡 HUD (顶部显示 Level / 倒计时)' }) + public autoBuildUI: boolean = true; + + private mgr: LevelMgr | undefined; + private hudNode: Node | null = null; + + protected async onLoad(): Promise { + if (this.autoBuildUI) this.buildDefaultUI(); + const cfg = new ConfigMgr(new CCJsonLoader()); + await cfg.load(); + this.mgr = new LevelMgr(cfg.level(this.levelId)); + } + + protected update(dt: number): void { + if (!this.mgr) return; + const status = this.mgr.tick(dt); + this.refreshHud(); + if (status === 'victory') { + director.loadScene(this.nextSceneName || this.deriveNextScene()); + } else if (status === 'timeout' || status === 'player_dead') { + director.loadScene('Settlement'); + } + } + + private deriveNextScene(): string { + const map: Record = { + '1-1': 'Level_1_2', + '1-2': 'Level_1_3', + '1-3': 'Level_1_4', + '1-4': 'Level_1_5', + '1-5': 'Boss_ShuangHuanFang', + }; + return map[this.levelId] ?? 'Settlement'; + } + + private buildDefaultUI(): void { + ensureCanvasSize(this.node); + createLabel(this.node, `Level ${this.levelId}`, 0, DESIGN_HEIGHT / 2 - 50, 28, Color.WHITE); + this.hudNode = createLabel( + this.node, + 'Time: --', + 0, + DESIGN_HEIGHT / 2 - 90, + 22, + new Color(255, 220, 120, 255), + ); + } + + private refreshHud(): void { + if (!this.hudNode || !this.mgr) return; + const lb = this.hudNode.getComponent(Label); + if (lb) { + const r = this.mgr.result(); + lb.string = `Time: ${Math.max(0, Math.ceil(r.remainingSec))}s Kills: ${Object.values(r.kills).reduce((a, b) => a + b, 0)}`; + } + } +} diff --git a/assets/scripts/scene_entries/LevelEntry.ts.meta b/assets/scripts/scene_entries/LevelEntry.ts.meta new file mode 100644 index 0000000..4dde1dc --- /dev/null +++ b/assets/scripts/scene_entries/LevelEntry.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "621ccf9a-6948-463f-9e06-bbb94027a369", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/scene_entries/MainMenuEntry.ts b/assets/scripts/scene_entries/MainMenuEntry.ts new file mode 100644 index 0000000..d5c3e95 --- /dev/null +++ b/assets/scripts/scene_entries/MainMenuEntry.ts @@ -0,0 +1,182 @@ +import { _decorator, Component, director, Node, Label, Button, UITransform, Color, Vec3, Graphics } from 'cc'; +import { UIFlowMgr, ISceneEnter } from '../ui/UIFlowMgr'; +import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants'; + +const { ccclass, property } = _decorator; + +/** Maps abstract SceneId → physical Cocos Creator scene name. */ +const SCENE_MAP: Record = { + boot: 'Boot', + story_intro: 'StoryIntro', + main_menu: 'MainMenu', + // level_select currently reuses MainMenu until a dedicated LevelSelect scene exists. + level_select: 'MainMenu', + // `gameplay` is dispatched by levelId — resolved at runtime. + gameplay: 'Level_1_1', + settlement: 'Settlement', + // Settings panel is overlayed on MainMenu for the MVP. + settings: 'MainMenu', +}; + +/** levelId → physical scene name mapping (chapter 1 only). */ +const LEVEL_SCENE_MAP: Record = { + '1-1': 'Level_1_1', + '1-2': 'Level_1_2', + '1-3': 'Level_1_3', + '1-4': 'Level_1_4', + '1-5': 'Level_1_5', + '1-5-boss': 'Boss_ShuangHuanFang', +}; + +/** + * MainMenu scene entry (task 9.2 hookup). + * + * Owns a `UIFlowMgr` instance and translates each abstract `onSceneEnter` + * callback into a concrete `director.loadScene` call. Attach this component + * to the root node of `MainMenu.scene`. + * + * When `autoBuildUI` is enabled (default), two centered buttons (Start / + * Settings) and a title label are created programmatically so the scene is + * usable out of the box even before any art pass. + */ +@ccclass('MainMenuEntry') +export class MainMenuEntry extends Component { + @property({ tooltip: '是否自动生成 Start / Settings 按钮 (方便没美术时就能跑通)' }) + public autoBuildUI: boolean = true; + + private flow: UIFlowMgr | undefined; + + protected onLoad(): void { + this.flow = new UIFlowMgr(undefined, { + onSceneEnter: (ev) => this.handleSceneEnter(ev), + }); + if (this.autoBuildUI) this.buildDefaultUI(); + } + + /** Bind this to the "Start" button's click event in the Inspector. */ + public onPressStart(): void { + this.flow?.onPressStartGame(); + } + + /** Bind this to the "Settings" button's click event. */ + public onPressSettings(): void { + this.flow?.onOpenSettings(); + } + + private handleSceneEnter(ev: ISceneEnter): void { + const payload = ev.payload ?? {}; + if (ev.scene === 'gameplay' && typeof payload.levelId === 'string') { + const physical = LEVEL_SCENE_MAP[payload.levelId]; + if (physical) { + director.loadScene(physical); + return; + } + } + const physical = SCENE_MAP[ev.scene]; + if (physical) director.loadScene(physical); + } + + // ------------------------------------------------------------------ + // Auto-built UI (development affordance; art pass will replace it) + // ------------------------------------------------------------------ + private buildDefaultUI(): void { + // Ensure the host node has a UITransform matching the design resolution. + ensureCanvasSize(this.node); + + // Title. + createLabel(this.node, '影 之 传 说', 0, 140, 44, Color.WHITE); + + // Start button (centered, 40 above origin). + createButton(this.node, 'Start', 0, 40, 220, 60, () => this.onPressStart()); + + // Settings button (centered, 40 below origin). + createButton(this.node, 'Settings', 0, -40, 220, 60, () => this.onPressSettings()); + + // Hint line at the bottom. + createLabel(this.node, 'Chapter 1 · MVP', 0, -200, 20, new Color(180, 180, 180, 255)); + } +} + +// ====================================================================== +// Shared UI helpers — intentionally kept inline (no external module) so +// each Scene Entry stays self-contained and easy to remove once real UI is +// authored in the editor. +// ====================================================================== + +function ensureCanvasSize(host: Node): void { + let ut = host.getComponent(UITransform); + if (!ut) ut = host.addComponent(UITransform); + ut.setContentSize(DESIGN_WIDTH, DESIGN_HEIGHT); + host.setPosition(0, 0, 0); +} + +function createLabel(parent: Node, text: string, x: number, y: number, fontSize: number, color: Color): Node { + const n = new Node('AutoLabel'); + n.layer = parent.layer; + parent.addChild(n); + const ut = n.addComponent(UITransform); + ut.setContentSize(DESIGN_WIDTH, fontSize * 1.6); + const lb = n.addComponent(Label); + lb.useSystemFont = true; + lb.string = text; + lb.fontSize = fontSize; + lb.lineHeight = Math.floor(fontSize * 1.2); + lb.color = color; + lb.horizontalAlign = 1; // CENTER + lb.verticalAlign = 1; // CENTER + n.setPosition(new Vec3(x, y, 0)); + return n; +} + +function createButton( + parent: Node, + text: string, + x: number, + y: number, + w: number, + h: number, + onClick: () => void +): Node { + const n = new Node(`Btn_${text}`); + n.layer = parent.layer; + parent.addChild(n); + const ut = n.addComponent(UITransform); + ut.setContentSize(w, h); + + // Background drawn via Graphics — avoids needing any texture asset. + const g = n.addComponent(Graphics); + g.fillColor = new Color(40, 40, 60, 230); + g.rect(-w / 2, -h / 2, w, h); + g.fill(); + g.strokeColor = new Color(200, 200, 220, 255); + g.lineWidth = 2; + g.rect(-w / 2, -h / 2, w, h); + g.stroke(); + + // Label child for the text. + const labelNode = new Node('Label'); + labelNode.layer = parent.layer; + n.addChild(labelNode); + const lut = labelNode.addComponent(UITransform); + lut.setContentSize(w, h); + const lb = labelNode.addComponent(Label); + lb.useSystemFont = true; + lb.string = text; + lb.fontSize = 24; + lb.lineHeight = 28; + lb.color = Color.WHITE; + lb.horizontalAlign = 1; + lb.verticalAlign = 1; + + const btn = n.addComponent(Button); + btn.transition = Button.Transition.SCALE; + btn.target = n; + btn.zoomScale = 0.95; + n.on(Node.EventType.TOUCH_END, onClick, n); + + n.setPosition(new Vec3(x, y, 0)); + return n; +} + +// Re-export helpers so sibling Scene Entries can reuse them. +export { ensureCanvasSize, createLabel, createButton }; diff --git a/assets/scripts/scene_entries/MainMenuEntry.ts.meta b/assets/scripts/scene_entries/MainMenuEntry.ts.meta new file mode 100644 index 0000000..e1a79a9 --- /dev/null +++ b/assets/scripts/scene_entries/MainMenuEntry.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "3dc13e83-a51d-4530-ae5d-1ebceb69550f", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/scene_entries/SettlementEntry.ts b/assets/scripts/scene_entries/SettlementEntry.ts new file mode 100644 index 0000000..f46a090 --- /dev/null +++ b/assets/scripts/scene_entries/SettlementEntry.ts @@ -0,0 +1,69 @@ +import { _decorator, Component, director, Label, Node, Color } from 'cc'; +import { ChapterSettlement, ISettlementStats } from '../logic/ChapterSettlement'; +import { ensureCanvasSize, createLabel, createButton } from './MainMenuEntry'; +import { DESIGN_HEIGHT } from '../common/Constants'; + +const { ccclass, property } = _decorator; + +/** + * Settlement scene entry (task 8.2 hookup, req 14.x). + * + * Attach to the root of `Settlement.scene`. All UI is auto-built by default + * (title, score, closing line, back-to-menu button). If `autoBuildUI` is + * disabled and you wire up `scoreLabelNode` / `closingLabelNode` manually, + * those wins. + */ +@ccclass('SettlementEntry') +export class SettlementEntry extends Component { + @property({ type: Node, tooltip: '得分 Label 节点 (可留空自动创建)' }) + public scoreLabelNode: Node | null = null; + + @property({ type: Node, tooltip: '结局旁白 Label 节点 (可留空自动创建)' }) + public closingLabelNode: Node | null = null; + + @property({ tooltip: '是否自动创建结算界面 (Label + Back 按钮)' }) + public autoBuildUI: boolean = true; + + protected onLoad(): void { + if (this.autoBuildUI) this.buildDefaultUI(); + + const defaultStats: ISettlementStats = { + totalScore: 0, + stageScore: 0, + comboCount: 0, + flawless: true, + remainingTimeSec: 0, + }; + const settlement = new ChapterSettlement(defaultStats); + const result = settlement.build(); + this.setLabel(this.scoreLabelNode, `Stage Score: ${result.stats.stageScore}`); + this.setLabel(this.closingLabelNode, result.closingLine); + } + + /** Bind to a "Back to Menu" button. */ + public onReturnToMenu(): void { + director.loadScene('MainMenu'); + } + + private setLabel(node: Node | null, text: string): void { + if (!node) return; + const label = node.getComponent(Label); + if (label) label.string = text; + } + + private buildDefaultUI(): void { + ensureCanvasSize(this.node); + // Title. + createLabel(this.node, '章 节 结 算', 0, DESIGN_HEIGHT / 2 - 70, 40, Color.WHITE); + // Score label (created only if inspector did not supply one). + if (!this.scoreLabelNode) { + this.scoreLabelNode = createLabel(this.node, '', 0, 40, 30, new Color(255, 220, 120, 255)); + } + // Closing line label. + if (!this.closingLabelNode) { + this.closingLabelNode = createLabel(this.node, '', 0, -30, 24, new Color(200, 200, 200, 255)); + } + // Bottom "Back to Menu" button. + createButton(this.node, 'Back to Menu', 0, -DESIGN_HEIGHT / 2 + 80, 240, 60, () => this.onReturnToMenu()); + } +} diff --git a/assets/scripts/scene_entries/SettlementEntry.ts.meta b/assets/scripts/scene_entries/SettlementEntry.ts.meta new file mode 100644 index 0000000..8ad5cb5 --- /dev/null +++ b/assets/scripts/scene_entries/SettlementEntry.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "cab3e1e4-d2db-4b2f-bb14-179cfb6243eb", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/scene_entries/StorySceneEntry.ts b/assets/scripts/scene_entries/StorySceneEntry.ts new file mode 100644 index 0000000..9d640f5 --- /dev/null +++ b/assets/scripts/scene_entries/StorySceneEntry.ts @@ -0,0 +1,90 @@ +import { _decorator, Component, director, Label, Node, Color, EventTouch, UITransform } from 'cc'; +import { ConfigMgr } from '../data/ConfigMgr'; +import { CCJsonLoader } from './CCJsonLoader'; +import { StorySceneCtrl } from '../ui/StorySceneCtrl'; +import { ensureCanvasSize, createLabel, createButton } from './MainMenuEntry'; +import { DESIGN_WIDTH, DESIGN_HEIGHT } from '../common/Constants'; + +const { ccclass, property } = _decorator; + +/** + * StoryIntro scene entry (task 9.1 hookup, req 19.x). + * + * Attach to the root of `StoryIntro.scene`. Inspector: + * - labelNode: optional — if left empty and `autoBuildUI` is true, a + * centered Label and a bottom-right "Skip" button are auto-created. + * - storyId: story id in `configs/stories.json` (default `chapter_1_intro`). + * + * Tap anywhere → accelerate / advance (req 19.3). + * "Skip" button → finish immediately (req 19.4). + */ +@ccclass('StorySceneEntry') +export class StorySceneEntry extends Component { + @property({ type: Node, tooltip: '打字机 Label 节点 (可留空,自动创建)' }) + public labelNode: Node | null = null; + + @property({ tooltip: '对应 configs/stories.json 中的 id' }) + public storyId: string = 'chapter_1_intro'; + + @property({ tooltip: '是否自动创建 Label / Skip 按钮 / 背景遮罩' }) + public autoBuildUI: boolean = true; + + private ctrl: StorySceneCtrl | undefined; + + protected async onLoad(): Promise { + if (this.autoBuildUI && !this.labelNode) { + this.buildDefaultUI(); + } + // Full-screen tap accelerator: listen on the host node itself. + this.node.on(Node.EventType.TOUCH_END, () => this.onTap(), this); + + const cfg = await this.loadStoryConfig(); + this.ctrl = new StorySceneCtrl(cfg, undefined, { + onTextChanged: (text) => this.updateLabel(text), + onFinished: () => director.loadScene('Level_1_1'), + }); + const outcome = this.ctrl.start(); + if (outcome === 'already_seen') { + director.loadScene('Level_1_1'); + } + } + + protected update(dt: number): void { + this.ctrl?.tick(dt); + } + + /** Tap handler (called by auto-built full-screen listener or external). */ + public onTap(): void { + this.ctrl?.onTap(); + } + + /** Bound to the "Skip" button. */ + public onSkip(): void { + this.ctrl?.onSkip(); + } + + private updateLabel(text: string): void { + if (!this.labelNode) return; + const label = this.labelNode.getComponent(Label); + if (label) label.string = text; + } + + private async loadStoryConfig() { + const mgr = new ConfigMgr(new CCJsonLoader()); + await mgr.load(); + return mgr.story(this.storyId); + } + + private buildDefaultUI(): void { + ensureCanvasSize(this.node); + // Ensure the root node can receive touch (size = design resolution). + // Central typewriter label. + this.labelNode = createLabel(this.node, '', 0, 0, 28, Color.WHITE); + const ut = this.labelNode.getComponent(UITransform); + if (ut) ut.setContentSize(DESIGN_WIDTH - 80, DESIGN_HEIGHT - 120); + // Skip button at bottom-right. + const skipX = DESIGN_WIDTH / 2 - 90; + const skipY = -DESIGN_HEIGHT / 2 + 50; + createButton(this.node, 'Skip >>', skipX, skipY, 140, 50, () => this.onSkip()); + } +} diff --git a/assets/scripts/scene_entries/StorySceneEntry.ts.meta b/assets/scripts/scene_entries/StorySceneEntry.ts.meta new file mode 100644 index 0000000..52a0985 --- /dev/null +++ b/assets/scripts/scene_entries/StorySceneEntry.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "2c3b5227-420d-4421-a500-36519776ea9d", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/ui.meta b/assets/scripts/ui.meta new file mode 100644 index 0000000..c37ab1c --- /dev/null +++ b/assets/scripts/ui.meta @@ -0,0 +1,9 @@ +{ + "ver": "1.2.0", + "importer": "directory", + "imported": true, + "uuid": "bc3870ea-323d-4754-b96b-d9d0db12b29e", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/ui/FloatingControlLayer.ts b/assets/scripts/ui/FloatingControlLayer.ts new file mode 100644 index 0000000..26d4856 --- /dev/null +++ b/assets/scripts/ui/FloatingControlLayer.ts @@ -0,0 +1,231 @@ +import { _decorator, Component, EventTouch, Node, Touch, UITransform, Vec2, Vec3, view } from 'cc'; +import { globalEventBus, globalLogger } from '../common/index'; +import { PERF_TOUCH_RESPONSE_MAX_MS } from '../common/Constants'; +import { + ControlId, + DEFAULT_LAYOUT, + IFloatingLayout, + ISafeAreaInsets, + MultiTouchRouter, + applySafeArea, + classifyDirection, + joystickDirection, +} from './InputModel'; +import { InputEvents } from './InputEvents'; + +const { ccclass, property } = _decorator; + +/** + * View component for the floating control layer. + * + * Responsibilities (req 1.x, 20.1): + * - Subscribe to all touch events on a full-screen UI node. + * - Translate device coordinates into landscape-design coordinates. + * - Delegate hit-testing / dead-zone / angle classification to `InputModel` + * (platform-agnostic, already unit-tested under Jest). + * - Emit high-level input events through `globalEventBus`. + * - Record touch→response latency to `globalLogger` for QA (req 20.1). + * + * IMPORTANT: This class intentionally avoids any gameplay logic so that the + * player controller (task 4.x) can be swapped / re-tested without touching + * the input layer. + */ +@ccclass('FloatingControlLayer') +export class FloatingControlLayer extends Component { + @property({ tooltip: 'The root node of the joystick visual (bg + handle).' }) + public joystickRoot: Node | null = null; + + @property({ tooltip: 'The root node of the jump button visual.' }) + public jumpRoot: Node | null = null; + + @property({ tooltip: 'The root node of the shuriken button visual.' }) + public shurikenRoot: Node | null = null; + + @property({ tooltip: 'The root node of the ninja-sword button visual.' }) + public ninjaSwordRoot: Node | null = null; + + private layout: IFloatingLayout = DEFAULT_LAYOUT; + private router: MultiTouchRouter = new MultiTouchRouter(DEFAULT_LAYOUT); + + protected onLoad(): void { + this.applyInitialLayout(); + this.bindTouchEvents(); + } + + protected onDestroy(): void { + this.unbindTouchEvents(); + } + + /** Public API — called by `UIFlowMgr` when safe-area changes. */ + public updateSafeArea(insets: ISafeAreaInsets): void { + this.layout = applySafeArea(DEFAULT_LAYOUT, insets); + this.router = new MultiTouchRouter(this.layout); + this.syncLayoutToNodes(); + } + + /** Public API — replace the layout (used by the layout-customisation flow, task 3.2). */ + public setLayout(layout: IFloatingLayout): void { + this.layout = layout; + this.router = new MultiTouchRouter(layout); + this.syncLayoutToNodes(); + } + + public getLayout(): IFloatingLayout { + return this.layout; + } + + // ------------------------------------------------------------------ + // internals + // ------------------------------------------------------------------ + + private applyInitialLayout(): void { + // On first frame the engine gives us a visibleSize in real pixels; + // we derive insets from it so the landscape 960x540 baseline still + // maps correctly into a notched screen (req 1.7). + const size = view.getVisibleSize(); + // Heuristic: if the device is wider than 16:9 we add insets on both + // left/right to keep controls in the safe area. + const baselineRatio = 16 / 9; + const actualRatio = size.width / size.height; + let leftInset = 0; + let rightInset = 0; + if (actualRatio > baselineRatio) { + const extra = (actualRatio - baselineRatio) * size.height; + leftInset = extra / 2 / (size.width / 960); + rightInset = leftInset; + } + this.updateSafeArea({ left: leftInset, right: rightInset, top: 0, bottom: 0 }); + } + + private bindTouchEvents(): void { + this.node.on(Node.EventType.TOUCH_START, this.onTouchStart, this); + this.node.on(Node.EventType.TOUCH_MOVE, this.onTouchMove, this); + this.node.on(Node.EventType.TOUCH_END, this.onTouchEnd, this); + this.node.on(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this); + } + + private unbindTouchEvents(): void { + this.node.off(Node.EventType.TOUCH_START, this.onTouchStart, this); + this.node.off(Node.EventType.TOUCH_MOVE, this.onTouchMove, this); + this.node.off(Node.EventType.TOUCH_END, this.onTouchEnd, this); + this.node.off(Node.EventType.TOUCH_CANCEL, this.onTouchEnd, this); + } + + private onTouchStart(ev: EventTouch): void { + const t = ev.getUILocation(); + const start = FloatingControlLayer.now(); + const touchId = this.touchId(ev); + const hit = this.router.begin(touchId, t.x, t.y, start); + this.recordLatency('input/touchStart', start); + if (!hit) { + // Let the touch fall through to the gameplay layer (req 1.3). + return; + } + ev.propagationStopped = true; + switch (hit) { + case ControlId.Jump: + globalEventBus.emit(InputEvents.JumpPressed, {}); + break; + case ControlId.Shuriken: + globalEventBus.emit(InputEvents.ShurikenPressed, {}); + break; + case ControlId.NinjaSword: + globalEventBus.emit(InputEvents.NinjaSwordPressed, {}); + break; + case ControlId.Joystick: + this.broadcastJoystick(t.x, t.y); + break; + default: + break; + } + } + + private onTouchMove(ev: EventTouch): void { + const t = ev.getUILocation(); + const touchId = this.touchId(ev); + const bound = this.router.move(touchId, t.x, t.y); + if (bound === ControlId.Joystick) { + this.broadcastJoystick(t.x, t.y); + } + } + + private onTouchEnd(ev: EventTouch): void { + const touchId = this.touchId(ev); + const end = FloatingControlLayer.now(); + const bound = this.router.end(touchId); + if (!bound) return; + switch (bound) { + case ControlId.Jump: { + const slotStart = this.lastStartTs.get(touchId); + const hold = slotStart !== undefined ? end - slotStart : 0; + globalEventBus.emit(InputEvents.JumpReleased, { holdMs: hold }); + break; + } + case ControlId.Shuriken: + globalEventBus.emit(InputEvents.ShurikenReleased, {}); + break; + case ControlId.NinjaSword: + globalEventBus.emit(InputEvents.NinjaSwordReleased, {}); + break; + case ControlId.Joystick: + globalEventBus.emit(InputEvents.JoystickMove, { dx: 0, dy: 0, klass: 'none' }); + break; + default: + break; + } + this.lastStartTs.delete(touchId); + } + + private broadcastJoystick(x: number, y: number): void { + const dir = joystickDirection(this.layout, x, y); + const klass = classifyDirection(dir); + globalEventBus.emit(InputEvents.JoystickMove, { dx: dir.x, dy: dir.y, klass }); + } + + /** Mirror layout geometry onto the bound visual nodes. */ + private syncLayoutToNodes(): void { + this.placeNode(this.joystickRoot, this.layout.joystick.cx, this.layout.joystick.cy); + this.placeNode(this.jumpRoot, this.layout.jump.cx, this.layout.jump.cy); + this.placeNode(this.shurikenRoot, this.layout.shuriken.cx, this.layout.shuriken.cy); + this.placeNode(this.ninjaSwordRoot, this.layout.ninjaSword.cx, this.layout.ninjaSword.cy); + } + + private placeNode(node: Node | null, cx: number, cy: number): void { + if (!node) return; + // Landscape design coordinates: origin at bottom-left of 960x540. + const worldX = cx - 480; + const worldY = cy - 270; + node.setPosition(new Vec3(worldX, worldY, 0)); + const ui = node.getComponent(UITransform); + if (ui) ui.setAnchorPoint(new Vec2(0.5, 0.5)); + } + + /** Map a Cocos `Touch` object to a stable numeric id we can track. */ + private readonly lastStartTs = new Map(); + private touchId(ev: EventTouch): number { + const touch: Touch | null = ev.touch; + const id = touch ? touch.getID() : 0; + if (!this.lastStartTs.has(id)) { + this.lastStartTs.set(id, FloatingControlLayer.now()); + } + return id; + } + + /** Capture input→emit latency into the perf metric store (req 20.1). */ + private recordLatency(name: string, start: number): void { + const elapsed = FloatingControlLayer.now() - start; + globalLogger.metric({ name, value: elapsed }); + if (elapsed > PERF_TOUCH_RESPONSE_MAX_MS) { + globalLogger.warn( + 'Input', + `latency ${elapsed.toFixed(1)}ms exceeds ${PERF_TOUCH_RESPONSE_MAX_MS}ms target` + ); + } + } + + private static now(): number { + return typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); + } +} diff --git a/assets/scripts/ui/FloatingControlLayer.ts.meta b/assets/scripts/ui/FloatingControlLayer.ts.meta new file mode 100644 index 0000000..f26a835 --- /dev/null +++ b/assets/scripts/ui/FloatingControlLayer.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "135fb141-7a56-4376-a09f-9e991ac191bf", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/ui/InputEvents.ts b/assets/scripts/ui/InputEvents.ts new file mode 100644 index 0000000..528119e --- /dev/null +++ b/assets/scripts/ui/InputEvents.ts @@ -0,0 +1,27 @@ +/** + * Event constants emitted by the floating control layer and consumed by the + * gameplay layer. Centralising them avoids typo-driven wiring bugs and gives + * Jest a place to assert against expected strings. + */ + +export const InputEvents = { + /** payload: `{ dx: number; dy: number; klass: JoystickAngleClass }` */ + JoystickMove: 'input/joystickMove', + /** payload: `{}` — jump button went down (req 2.2). */ + JumpPressed: 'input/jumpPressed', + /** payload: `{ holdMs: number }` — jump button released. */ + JumpReleased: 'input/jumpReleased', + /** payload: `{}` — shuriken button down. */ + ShurikenPressed: 'input/shurikenPressed', + ShurikenReleased: 'input/shurikenReleased', + /** payload: `{}` — ninja sword button down. */ + NinjaSwordPressed: 'input/ninjaSwordPressed', + NinjaSwordReleased: 'input/ninjaSwordReleased', + /** + * payload: `{ id: 'jump' | 'shuriken' | 'ninja_sword'; disabled: boolean; reason?: string }` + * — button must repaint (e.g. airborne → jump disabled, req 2.4). + */ + ButtonVisualChanged: 'input/buttonVisualChanged', +} as const; + +export type InputEventName = (typeof InputEvents)[keyof typeof InputEvents]; diff --git a/assets/scripts/ui/InputEvents.ts.meta b/assets/scripts/ui/InputEvents.ts.meta new file mode 100644 index 0000000..25b3704 --- /dev/null +++ b/assets/scripts/ui/InputEvents.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "b1386e5b-3b36-4b68-9854-d41efb7f7dc7", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/ui/InputModel.ts b/assets/scripts/ui/InputModel.ts new file mode 100644 index 0000000..a6f4af9 --- /dev/null +++ b/assets/scripts/ui/InputModel.ts @@ -0,0 +1,289 @@ +/** + * Input model for the floating control layer. + * + * This module is intentionally **free of `cc` dependencies** so that: + * - 45°/135° parabolic recognition (req 2.5, 20.3) + * - joystick dead-zone (req 1.5) + * - safe-area adaptation (req 1.7, 18.6) + * - multi-touch routing (req 1.3, 1.8) + * + * can all be unit-tested under Jest with deterministic coordinates. + * + * The Cocos Creator view layer (`FloatingControlLayer.ts`) is a thin adapter + * that forwards `TouchEvent` data into this model and renders whatever the + * model reports. + */ + +import { + DESIGN_WIDTH, + DESIGN_HEIGHT, + PARABOLIC_ANGLE_RIGHT, + PARABOLIC_ANGLE_LEFT, + PARABOLIC_ANGLE_TOLERANCE, +} from '../common/Constants'; + +/** Control IDs addressable by the HUD. */ +export enum ControlId { + Joystick = 'joystick', + Jump = 'jump', + Shuriken = 'shuriken', + NinjaSword = 'ninja_sword', +} + +/** + * A rectangular region defined in **landscape design coordinates** + * (origin at bottom-left, width=960, height=540). + * + * ┌─────────────────────────────┐ + * │ │ + * │ game world │ + * │ │ + * │ [joy] [S][K]│ ← joystick bottom-left, attacks bottom-right + * │ [J] │ ← jump above joystick-right + * └─────────────────────────────┘ + */ +export interface IHitRect { + /** x of the rect's center, in design pixels. */ + cx: number; + /** y of the rect's center, in design pixels. */ + cy: number; + /** Full width (design px). */ + w: number; + /** Full height (design px). */ + h: number; +} + +/** Landscape default layout — requirement 1.1. */ +export interface IFloatingLayout { + joystick: IHitRect; + jump: IHitRect; + shuriken: IHitRect; + ninjaSword: IHitRect; + /** Dead-zone radius inside the joystick (req 1.5). */ + joystickDeadzone: number; + /** Default opacity (0-1). Req 1.1 specifies 0.7. */ + opacity: number; +} + +export const DEFAULT_LAYOUT: IFloatingLayout = { + // Left-third safe area: joystick and jump stacked (req 1.1) + joystick: { cx: 120, cy: 100, w: 120, h: 120 }, + jump: { cx: 235, cy: 180, w: 90, h: 90 }, + + // Right-third safe area: two attack buttons side-by-side (req 1.1) + shuriken: { cx: DESIGN_WIDTH - 195, cy: 100, w: 90, h: 90 }, + ninjaSword: { cx: DESIGN_WIDTH - 85, cy: 100, w: 90, h: 90 }, + + joystickDeadzone: 10, + opacity: 0.7, +}; + +/** Direction vector, already normalised (or zero). */ +export interface IDirection { + x: number; + y: number; + /** Magnitude of the raw vector **before** normalisation. */ + magnitude: number; +} + +export const ZERO_DIRECTION: IDirection = Object.freeze({ x: 0, y: 0, magnitude: 0 }); + +/** + * Classification of a joystick direction relative to the parabolic trigger. + * + * - `none` — inside dead-zone. + * - `horizontal` — left/right movement, vertical jump allowed. + * - `parabolic_right` — ~45°, triggers ↗ parabolic jump (req 2.5). + * - `parabolic_left` — ~135°, triggers ↖ parabolic jump (req 2.5). + * - `other` — any other 2D vector. + */ +export type JoystickAngleClass = + | 'none' + | 'horizontal' + | 'parabolic_right' + | 'parabolic_left' + | 'other'; + +/** Clamp `v` to [min, max]. */ +export function clamp(v: number, min: number, max: number): number { + return v < min ? min : v > max ? max : v; +} + +/** + * Returns true if `(x, y)` lies inside `rect`. Origin is bottom-left. + * Used by both `isInside` and the touch router. + */ +export function isInsideRect(rect: IHitRect, x: number, y: number): boolean { + const halfW = rect.w / 2; + const halfH = rect.h / 2; + return Math.abs(x - rect.cx) <= halfW && Math.abs(y - rect.cy) <= halfH; +} + +/** Which control (if any) is hit at `(x, y)`. Priority: attack > jump > joystick. */ +export function hitTest(layout: IFloatingLayout, x: number, y: number): ControlId | null { + if (isInsideRect(layout.ninjaSword, x, y)) return ControlId.NinjaSword; + if (isInsideRect(layout.shuriken, x, y)) return ControlId.Shuriken; + if (isInsideRect(layout.jump, x, y)) return ControlId.Jump; + if (isInsideRect(layout.joystick, x, y)) return ControlId.Joystick; + return null; +} + +/** + * Compute a joystick direction vector from a touch point. Touches **outside** + * the joystick disc still map to a direction: we use the offset from the + * joystick centre (requirement 1.4). Inside the dead-zone the result is zero. + */ +export function joystickDirection(layout: IFloatingLayout, touchX: number, touchY: number): IDirection { + const dx = touchX - layout.joystick.cx; + const dy = touchY - layout.joystick.cy; + const mag = Math.hypot(dx, dy); + if (mag < layout.joystickDeadzone) { + return ZERO_DIRECTION; + } + return { x: dx / mag, y: dy / mag, magnitude: mag }; +} + +/** + * Map a direction vector into an `JoystickAngleClass` bucket. + * + * The canonical angles are: + * - 0° → right + * - 90° → up + * - 180° → left + * + * Parabolic trigger windows are 45°±15° and 135°±15° (req 2.5 + tolerance + * picked to stay within req 20.3's ≥95% recognition rate). + */ +export function classifyDirection(dir: IDirection): JoystickAngleClass { + if (dir.magnitude === 0) return 'none'; + // atan2 returns [-PI, PI]. Convert to [0, 360). + let deg = (Math.atan2(dir.y, dir.x) * 180) / Math.PI; + if (deg < 0) deg += 360; + // Pure horizontal (≤ ~10° off x-axis) treated as `horizontal`. + if (deg <= 10 || deg >= 350 || (deg >= 170 && deg <= 190)) return 'horizontal'; + if (Math.abs(deg - PARABOLIC_ANGLE_RIGHT) <= PARABOLIC_ANGLE_TOLERANCE) return 'parabolic_right'; + if (Math.abs(deg - PARABOLIC_ANGLE_LEFT) <= PARABOLIC_ANGLE_TOLERANCE) return 'parabolic_left'; + return 'other'; +} + +// --------------------------------------------------------------------------- +// Safe-area adaptation — requirement 1.7, 18.6 +// --------------------------------------------------------------------------- + +/** Screen aspect ratios handled without letterboxing (req 1.7). */ +export interface ISafeAreaInsets { + /** Px added on the left edge to avoid notches / sensors. */ + left: number; + right: number; + top: number; + bottom: number; +} + +/** + * Returns a shifted copy of `layout` that respects the given safe-area + * insets. The joystick group slides **rightwards** by `insets.left`; the + * attack group slides **leftwards** by `insets.right`; vertical shifts are + * symmetric. This keeps every control inside the device safe area without + * changing the relative geometry. + */ +export function applySafeArea(layout: IFloatingLayout, insets: ISafeAreaInsets): IFloatingLayout { + const shiftLeftGroup = (r: IHitRect): IHitRect => ({ + ...r, + cx: r.cx + insets.left, + cy: clamp(r.cy + insets.bottom, r.h / 2, DESIGN_HEIGHT - insets.top - r.h / 2), + }); + const shiftRightGroup = (r: IHitRect): IHitRect => ({ + ...r, + cx: r.cx - insets.right, + cy: clamp(r.cy + insets.bottom, r.h / 2, DESIGN_HEIGHT - insets.top - r.h / 2), + }); + return { + ...layout, + joystick: shiftLeftGroup(layout.joystick), + jump: shiftLeftGroup(layout.jump), + shuriken: shiftRightGroup(layout.shuriken), + ninjaSword: shiftRightGroup(layout.ninjaSword), + }; +} + +// --------------------------------------------------------------------------- +// Multi-touch router — requirement 1.3, 1.8 +// --------------------------------------------------------------------------- + +/** Payload stored per active finger. */ +interface TouchSlot { + control: ControlId | null; + x: number; + y: number; + /** Timestamp (ms) captured on touchstart — used for combo recognition. */ + startTs: number; +} + +/** + * Tracks all currently-down fingers and routes each to the appropriate + * control. Events that miss every button fall through to the game-world + * layer by reporting `control === null` (requirement 1.3). + */ +export class MultiTouchRouter { + private readonly slots = new Map(); + + constructor(private readonly layout: IFloatingLayout) {} + + /** Begin tracking a new finger. Returns the hit control (or null). */ + public begin(id: number, x: number, y: number, ts: number): ControlId | null { + const control = hitTest(this.layout, x, y); + this.slots.set(id, { control, x, y, startTs: ts }); + return control; + } + + /** Update an in-flight finger. Returns the same control it bound to. */ + public move(id: number, x: number, y: number): ControlId | null { + const slot = this.slots.get(id); + if (!slot) return null; + slot.x = x; + slot.y = y; + return slot.control; + } + + /** Release a finger. Returns the control it was bound to. */ + public end(id: number): ControlId | null { + const slot = this.slots.get(id); + this.slots.delete(id); + return slot?.control ?? null; + } + + /** Returns the joystick slot (if any finger is currently driving it). */ + public joystickSlot(): TouchSlot | undefined { + for (const s of this.slots.values()) { + if (s.control === ControlId.Joystick) return s; + } + return undefined; + } + + /** Convenience — is this control currently pressed? */ + public isPressed(control: ControlId): boolean { + for (const s of this.slots.values()) { + if (s.control === control) return true; + } + return false; + } + + /** Returns how many simultaneous fingers are currently tracked. */ + public get activeTouchCount(): number { + return this.slots.size; + } + + /** Returns the earliest-pressed start timestamp among currently-active controls. */ + public earliestPressTs(controls: ControlId[]): number | undefined { + let best: number | undefined; + for (const s of this.slots.values()) { + if (!s.control || !controls.includes(s.control)) continue; + if (best === undefined || s.startTs < best) best = s.startTs; + } + return best; + } + + public clear(): void { + this.slots.clear(); + } +} diff --git a/assets/scripts/ui/InputModel.ts.meta b/assets/scripts/ui/InputModel.ts.meta new file mode 100644 index 0000000..5113c92 --- /dev/null +++ b/assets/scripts/ui/InputModel.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "1feee932-4b48-4e7b-8dfa-6bf1fcec491b", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/ui/LayoutCustomizer.ts b/assets/scripts/ui/LayoutCustomizer.ts new file mode 100644 index 0000000..f31435c --- /dev/null +++ b/assets/scripts/ui/LayoutCustomizer.ts @@ -0,0 +1,121 @@ +import { DEFAULT_LAYOUT, IFloatingLayout, IHitRect } from './InputModel'; +import { StorageMgr, globalStorageMgr } from '../common/StorageMgr'; +import { STORAGE_KEY } from '../common/Constants'; + +/** + * Persisted representation of the user's custom control layout. + * + * We intentionally persist **deltas** on top of the design-baseline + * `DEFAULT_LAYOUT` rather than absolute positions: + * + * - Keeps old save data forward-compatible when we retune the baseline. + * - Keeps the stored blob under ~100 bytes (well within the 17.x budget). + * + * Requirement traceability: + * - req 1.6 — long-press customisation mode stores this payload. + * - req 17.2 — persists across sessions. + * - req 17.6 — any parse failure must fall back to the default, not crash. + */ +export interface ILayoutDelta { + /** Offset applied on top of the default rect, in landscape design px. */ + joystickOffset: { dx: number; dy: number }; + jumpOffset: { dx: number; dy: number }; + shurikenOffset: { dx: number; dy: number }; + ninjaSwordOffset: { dx: number; dy: number }; + /** Multipliers applied to default `w`/`h`. Clamped to 0.7 — 1.4. */ + buttonSizeScale: number; + /** UI opacity 0.3 — 1.0 (req 1.1 default 0.7). */ + opacity: number; +} + +export const DEFAULT_LAYOUT_DELTA: ILayoutDelta = { + joystickOffset: { dx: 0, dy: 0 }, + jumpOffset: { dx: 0, dy: 0 }, + shurikenOffset: { dx: 0, dy: 0 }, + ninjaSwordOffset: { dx: 0, dy: 0 }, + buttonSizeScale: 1.0, + opacity: 0.7, +}; + +/** Numeric clamps enforced on any delta the user (or stale storage) gives us. */ +export const LAYOUT_DELTA_BOUNDS = { + offsetPxMax: 240, + sizeScaleMin: 0.7, + sizeScaleMax: 1.4, + opacityMin: 0.3, + opacityMax: 1.0, +} as const; + +/** Clamp + sanitise a raw delta object received from storage. */ +export function sanitiseLayoutDelta(raw: Partial | null | undefined): ILayoutDelta { + if (!raw || typeof raw !== 'object') { + return { ...DEFAULT_LAYOUT_DELTA }; + } + const clamp = (v: number, lo: number, hi: number): number => { + if (typeof v !== 'number' || Number.isNaN(v)) return (lo + hi) / 2; + return v < lo ? lo : v > hi ? hi : v; + }; + const clampOffset = (o?: { dx?: number; dy?: number }) => ({ + dx: clamp(o?.dx ?? 0, -LAYOUT_DELTA_BOUNDS.offsetPxMax, LAYOUT_DELTA_BOUNDS.offsetPxMax), + dy: clamp(o?.dy ?? 0, -LAYOUT_DELTA_BOUNDS.offsetPxMax, LAYOUT_DELTA_BOUNDS.offsetPxMax), + }); + return { + joystickOffset: clampOffset(raw.joystickOffset), + jumpOffset: clampOffset(raw.jumpOffset), + shurikenOffset: clampOffset(raw.shurikenOffset), + ninjaSwordOffset: clampOffset(raw.ninjaSwordOffset), + buttonSizeScale: clamp( + raw.buttonSizeScale ?? 1, + LAYOUT_DELTA_BOUNDS.sizeScaleMin, + LAYOUT_DELTA_BOUNDS.sizeScaleMax + ), + opacity: clamp(raw.opacity ?? 0.7, LAYOUT_DELTA_BOUNDS.opacityMin, LAYOUT_DELTA_BOUNDS.opacityMax), + }; +} + +/** Apply a sanitised delta on top of the baseline default layout. */ +export function applyLayoutDelta(baseline: IFloatingLayout, delta: ILayoutDelta): IFloatingLayout { + const offsetRect = (r: IHitRect, off: { dx: number; dy: number }): IHitRect => ({ + cx: r.cx + off.dx, + cy: r.cy + off.dy, + w: r.w * delta.buttonSizeScale, + h: r.h * delta.buttonSizeScale, + }); + return { + joystick: offsetRect(baseline.joystick, delta.joystickOffset), + jump: offsetRect(baseline.jump, delta.jumpOffset), + shuriken: offsetRect(baseline.shuriken, delta.shurikenOffset), + ninjaSword: offsetRect(baseline.ninjaSword, delta.ninjaSwordOffset), + joystickDeadzone: baseline.joystickDeadzone, + opacity: delta.opacity, + }; +} + +/** + * Thin adapter over `StorageMgr` that handles the `kl_control_layout` key. + * The adapter always produces a valid `IFloatingLayout` — even when the + * underlying storage is corrupted (req 17.6). + */ +export class LayoutCustomizer { + constructor( + private readonly baseline: IFloatingLayout = DEFAULT_LAYOUT, + private readonly storage: StorageMgr = globalStorageMgr + ) {} + + /** Load the saved delta (or default) and produce the concrete layout. */ + public loadLayout(): { layout: IFloatingLayout; delta: ILayoutDelta } { + const raw = this.storage.get | null>(STORAGE_KEY.ControlLayout, null); + const delta = sanitiseLayoutDelta(raw); + return { layout: applyLayoutDelta(this.baseline, delta), delta }; + } + + /** Persist the given delta after sanitising it. */ + public saveDelta(delta: ILayoutDelta): void { + this.storage.set(STORAGE_KEY.ControlLayout, sanitiseLayoutDelta(delta)); + } + + /** Reset the layout back to the landscape default. */ + public reset(): void { + this.storage.remove(STORAGE_KEY.ControlLayout); + } +} diff --git a/assets/scripts/ui/LayoutCustomizer.ts.meta b/assets/scripts/ui/LayoutCustomizer.ts.meta new file mode 100644 index 0000000..6ad730e --- /dev/null +++ b/assets/scripts/ui/LayoutCustomizer.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "d66e41d9-2102-4866-864d-163852b8ffd0", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/ui/StorySceneCtrl.ts b/assets/scripts/ui/StorySceneCtrl.ts new file mode 100644 index 0000000..f80f237 --- /dev/null +++ b/assets/scripts/ui/StorySceneCtrl.ts @@ -0,0 +1,162 @@ +import { IStorySceneConfig, IStoryPageConfig } from '../data/Interfaces'; +import { StorageMgr, globalStorageMgr } from '../common/StorageMgr'; +import { STORAGE_KEY } from '../common/Constants'; + +/** + * Story-intro cutscene controller (task 9.1, req 19.1 — 19.9). + * + * Responsibilities: + * 1. Decide whether the intro must play (first-time gate, req 19.5). + * 2. Drive the 3-page typewriter sequence (req 19.2-19.3). + * 3. Honour taps (speed up printing) and "Skip" (immediate dismiss, req 19.4). + * 4. Persist the "seen" flag so it plays only once (req 19.5). + * 5. Provide a `reset()` API the Settings menu calls (req 19.6). + * + * The view layer binds `onTextChanged` / `onFinished` to render text and + * trigger the next scene load. + */ + +export type StoryPhase = 'idle' | 'typing' | 'waiting_next' | 'finished'; + +export interface IStorySceneCallbacks { + onTextChanged?: (text: string, page: IStoryPageConfig) => void; + onPageEntered?: (page: IStoryPageConfig) => void; + onFinished?: (skipped: boolean) => void; +} + +/** + * How many characters per real-time second a page types out. Boosts to + * `FAST_MULTIPLIER` while the user is tapping (req 19.3). + */ +export const BASE_TYPING_CPS = 30; +export const FAST_MULTIPLIER = 4; + +export class StorySceneCtrl { + private phase: StoryPhase = 'idle'; + private pageIndex = 0; + private cursor = 0; + private elapsedSecOnPage = 0; + private typingFast = false; + + constructor( + private readonly scene: IStorySceneConfig, + private readonly storage: StorageMgr = globalStorageMgr, + private readonly callbacks: IStorySceneCallbacks = {} + ) {} + + /** Returns true if the user has already seen / skipped the intro. */ + public hasBeenSeen(): boolean { + return this.storage.get(STORAGE_KEY.StoryIntroSeen, false); + } + + /** + * Called by the boot flow. If the intro was already consumed, the view + * should skip straight to the next scene; otherwise this begins playback. + */ + public start(): 'playing' | 'already_seen' { + if (this.hasBeenSeen()) { + return 'already_seen'; + } + this.phase = 'typing'; + this.pageIndex = 0; + this.cursor = 0; + this.elapsedSecOnPage = 0; + this.typingFast = false; + this.callbacks.onPageEntered?.(this.currentPage()); + this.emitText(); + return 'playing'; + } + + /** Call every frame with real-time delta. */ + public tick(dtSec: number): void { + if (this.phase !== 'typing') return; + this.elapsedSecOnPage += dtSec; + const page = this.currentPage(); + const cps = BASE_TYPING_CPS * (this.typingFast ? FAST_MULTIPLIER : 1); + const targetCursor = Math.floor(this.elapsedSecOnPage * cps); + if (targetCursor !== this.cursor) { + this.cursor = Math.min(targetCursor, page.text.length); + this.emitText(); + } + if (this.cursor >= page.text.length) { + this.phase = 'waiting_next'; + } + } + + /** Tap anywhere — speed up typewriter or advance to next page (req 19.3). */ + public onTap(): void { + if (this.phase === 'typing') { + // First tap: reveal full page immediately (req 19.3 "accelerate"). + const page = this.currentPage(); + this.cursor = page.text.length; + this.emitText(); + this.phase = 'waiting_next'; + return; + } + if (this.phase === 'waiting_next') { + this.advancePage(); + } + } + + /** Skip button pressed — immediate dismissal (req 19.4). */ + public onSkip(): void { + if (this.phase === 'finished') return; + this.markSeen(); + this.phase = 'finished'; + this.callbacks.onFinished?.(true); + } + + /** Called by the Settings screen to re-enable the intro (req 19.6). */ + public reset(): void { + this.storage.remove(STORAGE_KEY.StoryIntroSeen); + } + + /** Expose current page for HUD rendering. */ + public get currentPageNumber(): number { + return this.pageIndex + 1; + } + + /** Current visible text on the active page. */ + public get visibleText(): string { + return this.currentPage().text.slice(0, this.cursor); + } + + public get status(): StoryPhase { + return this.phase; + } + + public get totalPages(): number { + return this.scene.pages.length; + } + + // ----------------------------------------------------------------- + + private currentPage(): IStoryPageConfig { + return this.scene.pages[this.pageIndex]; + } + + private emitText(): void { + this.callbacks.onTextChanged?.(this.visibleText, this.currentPage()); + } + + private advancePage(): void { + if (this.pageIndex < this.scene.pages.length - 1) { + this.pageIndex++; + this.cursor = 0; + this.elapsedSecOnPage = 0; + this.phase = 'typing'; + this.typingFast = false; + this.callbacks.onPageEntered?.(this.currentPage()); + this.emitText(); + } else { + // Last page complete → finish naturally. + this.markSeen(); + this.phase = 'finished'; + this.callbacks.onFinished?.(false); + } + } + + private markSeen(): void { + this.storage.set(STORAGE_KEY.StoryIntroSeen, true); + } +} diff --git a/assets/scripts/ui/StorySceneCtrl.ts.meta b/assets/scripts/ui/StorySceneCtrl.ts.meta new file mode 100644 index 0000000..d27d2ff --- /dev/null +++ b/assets/scripts/ui/StorySceneCtrl.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "43ba3070-8f9a-436c-802f-71ed7bfab8eb", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/ui/UIFlowMgr.ts b/assets/scripts/ui/UIFlowMgr.ts new file mode 100644 index 0000000..957b78b --- /dev/null +++ b/assets/scripts/ui/UIFlowMgr.ts @@ -0,0 +1,125 @@ +import { StorageMgr, globalStorageMgr } from '../common/StorageMgr'; +import { STORAGE_KEY } from '../common/Constants'; + +/** + * UIFlowMgr — scene-flow state machine (task 9.2, req 12.7-12.8, 13.1). + * + * It does **not** perform `director.loadScene()` itself; the Cocos view layer + * subscribes to `onSceneEnter` and performs the actual scene swap. Keeping + * the flow engine-agnostic makes it trivial to Jest-test every boot path, + * every settlement path, and the new story-intro gate (req 19.x). + * + * Decision D-4 / req 13.1 guardrail: + * The `showDifficultyPicker()` action simply does not exist; and + * `availableSettingsEntries()` purposefully omits it. A future contributor + * who tries to add a "difficulty" key will hit a TypeScript compile-error + * because the union `SettingsKey` is exhaustive. + */ + +export type SceneId = + | 'boot' + | 'story_intro' + | 'main_menu' + | 'level_select' + | 'gameplay' + | 'settlement' + | 'settings'; + +export type SettingsKey = + | 'audio_volume' + | 'layout_customisation' + | 'replay_tutorial' + | 'replay_story_intro'; + +export interface ISceneEnter { + scene: SceneId; + /** Optional payload (e.g. `{ levelId: '1-1' }` for gameplay). */ + payload?: Record; +} + +export interface IUIFlowCallbacks { + onSceneEnter?: (ev: ISceneEnter) => void; +} + +export class UIFlowMgr { + private current: SceneId = 'boot'; + + constructor( + private readonly storage: StorageMgr = globalStorageMgr, + private readonly callbacks: IUIFlowCallbacks = {} + ) {} + + public get currentScene(): SceneId { + return this.current; + } + + /** Invoked by `GameBoot.start()` once engine is ready. */ + public onBoot(): void { + if (this.storage.get(STORAGE_KEY.StoryIntroSeen, false)) { + this.enter('main_menu'); + } else { + this.enter('story_intro'); + } + } + + /** Called by StorySceneCtrl.onFinished(). */ + public onStoryFinished(): void { + this.enter('gameplay', { levelId: '1-1' }); + } + + /** Main menu → level select. */ + public onPressStartGame(): void { + // First-time "Start Game" may jump through the story again if we + // ever reset; otherwise go to level select. + if (!this.storage.get(STORAGE_KEY.StoryIntroSeen, false)) { + this.enter('story_intro'); + } else { + this.enter('level_select'); + } + } + + public onPickLevel(levelId: string): void { + this.enter('gameplay', { levelId }); + } + + public onOpenSettings(): void { + this.enter('settings'); + } + + public onCloseSettings(): void { + this.enter('main_menu'); + } + + public onLevelCleared(nextLevelId: string | null): void { + if (nextLevelId) { + this.enter('settlement', { nextLevelId }); + } else { + // After final boss settlement, back to the main menu. + this.enter('settlement', { isChapterEnd: true }); + } + } + + public onSettlementContinue(nextLevelId?: string): void { + if (nextLevelId) this.enter('gameplay', { levelId: nextLevelId }); + else this.enter('main_menu'); + } + + public onPlayerDied(currentLevelId: string): void { + this.enter('settlement', { levelId: currentLevelId, dead: true }); + } + + /** + * Exhaustive list of settings entries available in the Settings scene. + * Purposefully omits any difficulty-selection entry (req 13.1). + */ + public availableSettingsEntries(): ReadonlyArray { + return ['audio_volume', 'layout_customisation', 'replay_tutorial', 'replay_story_intro']; + } + + // ----------------------------------------------------------------- + + private enter(scene: SceneId, payload?: Record): void { + this.current = scene; + this.callbacks.onSceneEnter?.({ scene, payload }); + } +} diff --git a/assets/scripts/ui/UIFlowMgr.ts.meta b/assets/scripts/ui/UIFlowMgr.ts.meta new file mode 100644 index 0000000..d17a0cc --- /dev/null +++ b/assets/scripts/ui/UIFlowMgr.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "0cab93e7-afa4-407b-93ef-67686fcb7110", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/assets/scripts/ui/index.ts b/assets/scripts/ui/index.ts new file mode 100644 index 0000000..8494ffe --- /dev/null +++ b/assets/scripts/ui/index.ts @@ -0,0 +1,11 @@ +/** + * UI layer — floating control layer, layout persistence, story cutscene, + * scene-flow manager, HUD, main menu. + */ + +export * from './InputModel'; +export * from './InputEvents'; +export * from './LayoutCustomizer'; +export * from './FloatingControlLayer'; +export * from './StorySceneCtrl'; +export * from './UIFlowMgr'; diff --git a/assets/scripts/ui/index.ts.meta b/assets/scripts/ui/index.ts.meta new file mode 100644 index 0000000..b61bbf3 --- /dev/null +++ b/assets/scripts/ui/index.ts.meta @@ -0,0 +1,9 @@ +{ + "ver": "4.0.24", + "importer": "typescript", + "imported": true, + "uuid": "7be3334f-8d77-4356-b1ae-1ae930a5db4d", + "files": [], + "subMetas": {}, + "userData": {} +} diff --git a/build-templates/wechatgame/game.json b/build-templates/wechatgame/game.json new file mode 100644 index 0000000..642d797 --- /dev/null +++ b/build-templates/wechatgame/game.json @@ -0,0 +1,18 @@ +{ + "deviceOrientation": "landscape", + "showStatusBar": false, + "networkTimeout": { + "request": 10000, + "downloadFile": 30000 + }, + "subpackages": [ + { + "name": "chapter1", + "root": "assets/resources/chapter1" + } + ], + "workers": "workers/request", + "renderer": "webgl", + "openDataContext": "", + "resizable": false +} diff --git a/doc/影之传说.md b/doc/影之传说.md new file mode 100644 index 0000000..09b29cc --- /dev/null +++ b/doc/影之传说.md @@ -0,0 +1,91 @@ +基于1985年日本TAITO开发的经典动作游戏FC《影之传说》(The Legend of Kage)核心资料,为你整理一份可用于微信小游戏复刻的详细策划案: + +一、项目基础设定 + +- 游戏名称:《影之传说:忍者救公主》(暂定,规避原IP直接命名) +• 核心背景:江户时代末期,魔忍军团劫持城主之女“雾姬公主”,玩家操控伊贺上忍“影”突破关卡、击败BOSS完成营救。 + +- 平台适配:微信小游戏,包体≤4MB,首包仅加载核心资源,后续动态加载关卡内容,适配单指触控操作。 + +二、核心玩法与操作 + +2.1 操作方案(适配移动端) + +操作手势 对应动作 说明 + +点击屏幕 发射手里剑/挥刀 自动瞄准最近敌人,近距离触发近战刀斩 + +长按+上滑 蓄力高跳 还原原作“一跳一屏高”的夸张跳跃手感,起跳有短暂下蹲延迟 + +左右滑动 横向微调 配合自动卷轴调整站位,避免空中无可控性的原作硬核设定(可选开关保留硬核模式) + +双击 闪避 短距离位移,带短暂无敌帧 + +2.2 核心机制 + +• 一击即死:原作硬核设定,触碰敌人攻击判定(手里剑、刀斩、烟玉、火球)直接死亡,可开启“轻度模式”改为3条命+受击无敌。 + +• 格挡系统:挥刀可格挡敌人手里剑、近战刀斩,但无法格挡红忍的烟玉、妖僧的火球,是操作上限核心。 + +- 能力升级:拾取敌人掉落的“水晶玉”升级,1个变绿衣(可扛1次普通攻击,手里剑变大加速),2个变黄衣(移速加快,仍被火球瞬杀)。 +• 随机道具: + + • 点丸/术丸:森场景每杀3个红忍随机掉落,提供临时增益 + + - 卷物(魔笛):仅城壁关黑忍掉落,秒杀全屏敌人 + • 红魂/白魂/蓝魂:各关卡专属随机道具,提供隐身、高分、额外生命等效果 + +三、角色与敌人设计 + +3.1 主角 + +• 影:伊贺上忍,初始红衣一击即死,武器为刀(近战)+手里剑(远程),可通过水晶玉升级外观与能力。 + +3.2 敌人详情 + +敌人类型 出现场景 攻击方式 特性 + +青忍 全场景 远投十字手里剑/近身刀斩 常规远程兵,可被格挡 + +赤忍(红忍) 森林/城墙/魔城 近身刀斩/追踪烟玉(无法格挡) 移动快,玩家停留原地会跳到前方投弹 + +黑忍 仅城壁关第2屏 近身刀斩 打倒掉落卷物,消失后不复现 + +妖坊(妖僧) 森林 直线火球(无法格挡,一击瞬杀) 突然现身,喷火速度快,需优先远程击杀 + +妖珠坊 森林关底 强化喷火/头撞 打倒3个妖坊后出现,半血变大,喷火范围更广 + +双幻坊 青叶之章BOSS 双人夹击+火球 需先打落变色蝴蝶才会显形受击 + +雾雪之介 红叶之章BOSS 二刀流近战,前后跳跃 可格挡其刀斩,同样需先打蝴蝶 + +雪草妖四郎 雪之章BOSS 全速冲撞近战 以天草四郎为原型,原作存在卡树BUG,复刻可优化 + +四、关卡与流程设计 + +游戏共3大章(青叶/红叶/雪之章),每章5个小节,共15关+3场BOSS战,每章背景色调对应春夏秋冬: +1. 森林(横向卷轴):打倒3个妖坊→击败妖珠坊,忌乱跳,易被妖僧火球命中,可拾取卷物秒杀妖僧 +2. 密道/护城河(横向卷轴):水陆并行,全灭10个青忍过关,有专属蓝魂道具奖命 +3. 城墙(垂直卷轴):向上跳跃攀爬,赤忍干扰,寻找黑忍拿卷物,仅随机出现1次 +4. 魔城/天守阁:4层楼梯,上楼梯时全身破绽需谨慎,抵达顶层准备救公主时,发现公主已被青忍带走,为后续BOSS战铺垫 +5. BOSS对决:每章结尾,必须先击落场上飞舞的蝴蝶(会变色),BOSS才会显形受击,一击必杀 + +每通过一章(击败本章BOSS),敌人移速+10%,3章全部通关后达成结局(最终章救出公主),可设置“无限循环模式”适配怀旧玩家。 + +五、商业化与社交(微信小游戏适配) + +• 广告变现:激励视频复活、双倍结算奖励、临时能力增益;低频插屏+底部Banner + +• 社交功能:微信好友排行榜(通关时间/救公主次数/高分)、成绩分享卡片、赠送体力、好友影子助战 + +- 留存设计:每日任务、每周特殊规则挑战、每2个月主题赛季更新、收集类成就 + +六、复刻注意事项 + +• 美术需原创像素重绘,避免直接挪用原ROM资源,规避版权风险 + +• 可保留“硬核原作模式”和“轻度适配模式”双模式,兼顾老玩家与新用户 + +- 保留原作核心手感:跳跃延迟、格挡判定、一击即死机制,是情怀核心点 + +如果需要针对某个模块(比如数值平衡、BOSS战细节、操作优化)进一步细化,可以随时告诉我。 \ No newline at end of file diff --git a/doc/影之传说_详细策划案.md b/doc/影之传说_详细策划案.md new file mode 100644 index 0000000..8a13ec9 --- /dev/null +++ b/doc/影之传说_详细策划案.md @@ -0,0 +1,797 @@ +# 《影之传说:忍者救公主》详细游戏策划方案 + +## 一、项目概述与市场分析 + +### 1.1 项目定位 +- **游戏名称**:《影之传说:忍者救公主》 +- **游戏类型**:横版动作闯关类微信小游戏 +- **核心玩法**:忍者跑酷+动作战斗+BOSS挑战 +- **目标平台**:微信小游戏平台 +- **目标用户**:80-90后怀旧玩家(25-40岁)+轻度休闲玩家(18-25岁) +- **开发周期**:3个月(MVP版本)+ 1个月(优化迭代) +- **团队规模**:5-8人(策划2人,程序2人,美术3人,测试1人) + +### 1.2 市场分析 +- **竞品分析**: + - 《忍者必须死》:美术风格华丽,操作复杂,轻度用户上手门槛高 + - 《火柴人忍者》:玩法简单但缺乏深度,留存率较低 + - 《超级马里奥Run》:经典IP改编,验证了横版跑酷玩法在移动端的可行性 +- **机会点**: + 1. 经典IP情怀复刻,吸引80-90后怀旧玩家 + 2. 微信小游戏平台流量红利期 + 3. 动作类游戏在小游戏平台相对稀缺 + 4. 双模式设计兼顾硬核玩家和轻度用户 + +### 1.3 SWOT分析 +- **优势(Strengths)**: + - 经典IP情怀价值 + - 玩法核心简单易懂 + - 双模式设计覆盖更广用户群体 + - 微信社交生态赋能 +- **劣势(Weaknesses)**: + - 像素美术风格可能不符合现代审美 + - 一击即死机制可能造成高流失 + - 缺乏IP授权存在版权风险 +- **机会(Opportunities)**: + - 微信小游戏用户规模持续增长 + - 怀旧游戏市场兴起 + - 广告变现模式成熟 +- **威胁(Threats)**: + - 竞品众多,同质化严重 + - 版号政策风险 + - 用户审美疲劳 + +## 二、核心玩法深度设计 + +### 2.1 操作方案优化 +#### 2.1.1 移动端操作适配(按钮操作方案) +**按钮布局设计(悬浮图层,参考《王者荣耀》风格)**: + +**设计原则**: +1. **悬浮透明图层**:所有操作按钮以半透明图层形式悬浮在游戏画面上方 +2. **视觉优先级**:按钮设计简洁现代,不遮挡关键游戏内容 +3. **自定义布局**:玩家可拖动按钮调整位置,系统记忆个人偏好 +4. **响应式设计**:根据不同屏幕尺寸和比例自动优化布局 + +**按钮位置与视觉层级(优化跳跃攻击同步操作)**: +- **虚拟摇杆**:固定在左下角,直径80px,70%透明度(左手拇指操作) +- **跳跃按钮**:固定在虚拟摇杆上方,直径60px,70%透明度(左手食指或拇指上滑操作) +- **攻击按钮**:固定在右下角,直径70px,70%透明度,攻击时100%不透明(右手拇指操作) +- **道具切换按钮**:固定在右上角,直径40px,70%透明度(右手食指操作) + +**设计理念**:左右手分工,左手控制移动+跳跃,右手控制攻击+切换,实现跳跃攻击自然同步操作 + +**悬浮图层特性**: +- **独立渲染层**:按钮在独立的UI图层渲染,不与游戏场景混合 +- **动态透明度**:战斗时70%透明度,非战斗时可调整为50%透明度 +- **触控优先**:按钮区域触控优先级最高,确保操作响应 +- **手势穿透**:非按钮区域的触控事件可穿透到游戏层 + +| 按钮类型 | 尺寸 | 位置 | 功能说明 | 视觉反馈 | +|---------|------|------|---------|---------| +| **虚拟摇杆** | 直径80px | 左下角区域(固定位置) | 八方向控制:左/右移动,上/下微调跳跃高度;轻推慢走,重推快跑;
**抛物线跳跃移动**:与跳跃按钮同时操作时,摇杆角度决定跳跃轨迹:45°方向(右上角)触发向右抛物线跳跃,135°方向(左上角)触发向左抛物线跳跃;
**区域外点击解释**:即使点击在摇杆可见区域外,系统也会计算从摇杆中心到点击点的方向向量,并据此控制角色移动,扩大有效输入区域;
**45°/135°抛物线轨迹**:摇杆停留在45°或135°区域时按下跳跃按钮,触发抛物线跳跃(轨迹如 `↗` 或 `↖`),而非标准垂直跳跃 | 摇杆中心跟随手指移动,边缘发光提示激活状态;停留在45°/135°区域时显示抛物线轨迹预览光效;与跳跃同时操作时显示同步光效和抛物线轨迹动画 | +| **跳跃按钮** | 直径60px | 左下角虚拟摇杆上方区域(可调整) | 单次点击:标准垂直跳跃(高度250px);长按0.5秒:蓄力高跳(高度375px);
**物理限制**:只有地面支撑时可跳跃,空中无支撑物时不能跳跃(真实物理模拟,禁用二段跳);
**抛物线跳跃**:与摇杆在45°或135°方向同时操作,触发抛物线轨迹跳跃(如 `↗` 或 `↖`);
**跳跃攻击组合**:与攻击按钮同时操作时,自动触发跳跃中攻击,支持移动+跳跃+攻击三合一操作 | 按下时按钮缩小10%,冷却时显示进度环;
**状态反馈**:可跳跃时按钮高亮,不可跳跃(空中)时半透明;
**抛物线指示**:与摇杆在45°/135°方向时显示抛物线轨迹指示器;与攻击同时操作时显示组合光效 | +| **手里剑攻击按钮** | 直径60px | 右下角区域(偏左位置,可调整) | 点击发射手里剑(标准/升级版);长按进入手里剑连射模式(最多3连发);滑动到敌人方向实现精确瞄准;
**自动升级支持**:拾取水晶玉后,手里剑自动从标准版升级为升级版,按钮效果相应增强;
**跳跃攻击组合**:与跳跃按钮同时操作时,自动触发跳跃中手里剑攻击;
**抛物线攻击组合**:在45°/135°抛物线跳跃过程中可同时操作手里剑攻击;
**武器互斥**:与忍者刀攻击按钮互斥,同时只能激活一个攻击按钮,当前激活的按钮高亮显示 | 按下时显示蓝色光效,冷却期间半透明显示;组合操作时显示连击特效;
**升级状态显示**:标准手里剑时按钮蓝色边框,升级后按钮显示金色特效边框 | +| **忍者刀攻击按钮** | 直径60px | 右下角区域(偏右位置,可调整) | 点击触发近战挥砍;长按可蓄力重斩;
**攻防一体**:使用忍者刀攻击时,可同时格挡敌人的刀斩和手里剑,但不能格挡烟玉和火球;
**跳跃攻击组合**:与跳跃按钮同时操作时,自动触发跳跃中近战攻击;
**武器互斥**:与手里剑攻击按钮互斥,同时只能激活一个攻击按钮,当前激活的按钮高亮显示;
**无自动升级**:忍者刀性能固定,专注于格挡与近战 | 按下时显示红色光效,冷却期间半透明显示;
**格挡特效**:成功格挡时显示特殊光效和音效;
**当前武器高亮**:激活时按钮100%不透明,未激活时70%透明度 | + +**悬浮图层交互特性**: +1. **动态透明度管理**:默认70%半透明悬浮,战斗时透明度降低到60%确保操作可见,按下时变为100%不透明 +2. **智能防误触**:相邻按钮最小间距15px,结合按压区域检测和手势识别防止误操作 +3. **响应式悬浮布局**:根据设备屏幕尺寸和比例自动优化悬浮位置和大小,确保在不同设备上的操作舒适度 +4. **个性化悬浮设置**:玩家可自由拖动悬浮按钮调整位置、大小和透明度,系统记忆个人偏好设置 +5. **悬浮引导系统**:新手阶段悬浮按钮逐个高亮引导,引导结束后恢复半透明状态 +6. **图层层级管理**:悬浮按钮在独立UI层渲染,确保触控响应优先级高于游戏场景交互 + +**悬浮图层操作优化(支持抛物线移动+物理跳跃限制)**: +- **摇杆死区优化**:中心10px半径内视为无输入,提供更精准的方向控制(参考《王者荣耀》方向轮盘优化) +- **抛物线角度识别区**:摇杆外围设置45°(右上)和135°(左上)专属角度识别区,当摇杆停留在该区域时触发抛物线跳跃轨迹预览 +- **悬浮触控优先级**:悬浮按钮层触控响应优先,确保操作即时性,图层触控响应延迟<50ms +- **多点触控与物理操作**: + - **抛物线跳跃移动**:同时操作摇杆(45°/135°方向)和跳跃按钮,触发抛物线轨迹跳跃(如 `↗` 或 `↖`),而非垂直跳跃 + - **跳跃物理限制检测**:实时检测玩家是否在地面或有支撑物,只有在支撑状态下跳跃按钮才可激活,空中状态下禁用跳跃 + - **跳跃中攻击**:跳跃按钮和攻击按钮可同时按下,系统智能识别为跳跃攻击组合 + - **抛物线攻击组合**:45°/135°抛物线跳跃过程中同时攻击,实现抛物线轨迹中的攻击动作 + - 支持3点以上同时触控,特别优化左手区域(摇杆角度识别+跳跃物理限制)的同时操作 +- **双攻击按钮互斥系统**: + - **按钮互斥管理**:系统确保手里剑攻击按钮和忍者刀攻击按钮不能同时激活,一次只能使用一个攻击按钮,当前激活的按钮高亮显示,未激活的按钮半透明 + - **攻击优先权**:当玩家点击一个攻击按钮时,该按钮立即激活,另一个攻击按钮自动转为未激活状态,无需手动切换 + - **自动升级无缝衔接**:手里剑拾取水晶玉后自动升级,攻击力增强,攻击按钮效果相应变化,但攻击按钮位置和功能不变 + - **互斥防冲突**:系统确保两个攻击按钮不会同时响应,防止操作冲突 +- **物理状态反馈系统**: + - **跳跃状态实时反馈**:跳跃按钮根据玩家物理状态变化:地面可跳跃时高亮,空中不可跳跃时半透明 + - **抛物线轨迹预览**:摇杆停留在45°/135°区域时显示抛物线轨迹预览线 + - **物理碰撞检测反馈**:跳跃过程中碰撞到障碍物时显示碰撞特效 +- **图层反馈延迟优化**:悬浮按钮按下到视觉反馈延迟<30ms,提供即时操作反馈感;物理状态变化反馈延迟<100ms;组合操作时显示特殊视觉反馈;武器互斥状态实时显示 +- **防误触与组合识别算法**:结合触控点大小、压力感应和时间判断,智能区分误触和有意组合操作,特别优化45°/135°角度识别的准确性 + +#### 2.1.2 悬浮操作新手引导 +1. **第1关:悬浮操作基础教学** + - **悬浮按钮熟悉**:引导玩家认识悬浮虚拟摇杆、攻击按钮和跳跃按钮的位置与功能 + - **悬浮攻击教学**:自动引导点击悬浮攻击按钮发射手里剑(击杀2个青忍),强调悬浮按钮的即时反馈 + - **悬浮移动教学**:教学使用悬浮虚拟摇杆控制角色移动,展示区域外点击的方向识别特性 + - **悬浮跳跃教学**:教学点击悬浮跳跃按钮进行蓄力跳跃通过障碍 +2. **第2关:悬浮操作进阶教学** + - **悬浮精确操作**:教学预判敌人攻击轨迹并使用悬浮摇杆精确移动躲避 + - **抛物线跳跃教学**:教学同时操作虚拟摇杆(45°或135°方向)和跳跃按钮,实现抛物线轨迹跳跃(如 `↗` 或 `↖`),教学物理限制:只有地面支撑时可跳跃,空中不能跳跃 + - **双攻击按钮互斥教学**:教学使用右下角两个独立攻击按钮(手里剑攻击按钮和忍者刀攻击按钮),演示按钮互斥性:点击一个攻击按钮时激活该武器,另一个自动转为未激活状态,一次只能使用一个攻击按钮 + - **悬浮攻防一体**:教学使用悬浮攻击按钮实现忍者刀的攻防一体机制(近战攻击同时可格挡) + - **悬浮跳跃攻击组合**:教学同时操作左手跳跃按钮和右手攻击按钮,实现跳跃中攻击空中敌人,强调左右手分工优势 + - **自动升级机制教学**:教学拾取水晶玉后,若当前使用手里剑攻击按钮,则手里剑自动升级,攻击力增强,攻击按钮效果变化;若使用忍者刀攻击按钮,则忍者刀保持原样,需要切换到手里剑攻击按钮才能享受升级效果 + - **移动+跳跃+攻击三合一**:教学同时操作摇杆(移动)、跳跃按钮(跳跃)、攻击按钮(攻击),实现全方位战斗操作 +3. **第3关:BOSS战教学** + - 教学打蝴蝶显形机制 + - 教学BOSS攻击模式识别 + - 教学一击必杀时机把握 + +### 2.2 战斗系统深化 +#### 2.2.1 伤害判定系统 +```python +# 伤害判定逻辑伪代码 +def check_damage(player, enemy): + if player.is_invincible: # 无敌帧期间 + return False + + # 检查是否正在使用忍者刀攻击(攻防一体机制) + if player.is_attacking_with_katana: + if enemy.attack_type in ["shuriken", "sword"]: + # 格挡判定:忍者刀可以格挡手里剑和刀斩 + return False # 成功格挡 + else: + # 无法格挡的攻击类型(烟玉/火球) + return True + + # 距离判定 + distance = calculate_distance(player, enemy) + if enemy.attack_type == "fireball": + if distance < 100: # 火球判定范围 + return True + elif enemy.attack_type == "smoke_bomb": + if distance < 80: # 烟玉判定范围 + return True + + return False +``` + +#### 2.2.2 武器系统(优化武器互斥与自动升级) +| 武器类型 | 对应攻击按钮 | 攻击方式 | 伤害值 | 攻击速度 | 特性 | 按钮互斥与升级机制 | +|---------|-------------|---------|--------|---------|------|------------------| +| 标准手里剑 | 手里剑攻击按钮 | 远程投射 | 1 | 0.3秒/发 | 基础远程攻击 | **按钮互斥**:与忍者刀攻击按钮互斥,点击手里剑攻击按钮时激活该武器,忍者刀攻击按钮自动转为未激活状态
**自动升级**:拾取水晶玉后自动升级为强化版,攻击按钮效果变化但位置不变 | +| 升级手里剑 | 手里剑攻击按钮 | 远程投射 | 2 | 0.25秒/发 | 体积增大,速度加快 | **按钮互斥**:仍与忍者刀攻击按钮互斥,使用升级手里剑时,忍者刀攻击按钮保持未激活状态
**自动强化**:拾取第2个水晶玉后速度进一步提升,攻击按钮显示升级特效 | +| 忍者刀 | 忍者刀攻击按钮 | 近战挥砍 | 3 | 0.5秒/次 | 可格挡远程攻击,攻防一体 | **按钮互斥**:与手里剑攻击按钮(标准/升级)互斥,点击忍者刀攻击按钮时激活该武器,手里剑攻击按钮自动转为未激活状态
**无自动升级**:性能固定,攻击按钮效果不变,专注于格挡与近战 | + +### 2.3 能力升级系统 +#### 2.3.1 水晶玉升级路径(优化自动升级机制) +1. **初始状态(红衣)**: + - 生命:1(一击即死) + - 移速:1.0 + - 攻击:标准手里剑(自动使用) +2. **第一阶段(绿衣)**: + - 生命:1(可抵挡1次普通攻击,被击中后立即变回红衣,所有强化效果清零) + - 移速:1.0 + - 攻击:**自动升级为手里剑**(攻击力2,体积增大) + - 外观:绿色忍者服 + - **自动升级机制**:拾取第1个水晶玉后,标准手里剑立即自动升级,无需手动切换 +3. **第二阶段(黄衣)**: + - 生命:1(可抵挡1次普通攻击,被击中后立即变回红衣,所有强化效果清零) + - 移速:1.5 + - 攻击:**进一步强化升级手里剑**(攻击力2,速度0.25秒/发) + - 外观:黄色忍者服 + - 特性:冲刺距离增加20% + - **自动升级机制**:拾取第2个水晶玉后,升级手里剑性能自动强化,保持攻击方式不变 + +#### 2.3.2 临时增益道具 +| 道具名称 | 出现场景 | 效果 | 持续时间 | +|---------|---------|------|---------| +| 点丸 | 森林关 | 攻击力+50% | 30秒 | +| 术丸 | 森林关 | 移动速度+30% | 20秒 | +| 卷物(魔笛) | 城壁关 | 秒杀全屏敌人 | 立即生效 | +| 增丸 | 密道关 | 增加一条命(永久有效) | 永久 | + +### 2.4 原作机制深度还原(基于玩家体验反馈) +#### 2.4.1 攻击与格挡系统 +- **刀剑双功能机制**:忍者刀不仅是近战武器,还能格挡敌人的刀斩和手里剑(实现"刃刃相消"效果),但不能格挡赤忍的烟玉和妖僧的火球。这种"攻防一体"的设计是原作操作感的核心,将在微信版中完整保留。 +- **防御判定**:使用忍者刀攻击时,对可格挡攻击(刀斩/手里剑)实现100%防御,完美时机格挡触发反击;对不可格挡攻击(烟玉/火球)仍会受伤。 + +#### 2.4.2 跳跃机制优化 +- **起跳延迟**:保留原作起跳前的下蹲延迟(约150ms),但可根据难度模式调整(硬核模式保留,轻度模式缩短)。 +- **空中轨迹**:硬核模式中,跳跃过程中无法调整轨迹,还原原作"跳跃时极易成为活靶子"的设计;轻度模式中,允许小幅度的空中移动调整。 +- **跳跃策略**:游戏中将通过引导提示"不准乱跳",鼓励玩家采用地面战斗策略,仅在必要时机使用跳跃。 + +#### 2.4.3 衣服状态系统细化(优化自动升级与武器互斥) +| 状态等级 | 获取条件 | 生命值 | 移动速度 | 攻击方式 | 按钮互斥与升级机制 | 弱点 | +|---------|---------|--------|---------|---------|-------------------|------| +| **红衣(初始)** | 开局默认 | 1(一击即死) | 1.0 | 标准手里剑 | **按钮互斥**:两个攻击按钮(手里剑/忍者刀)互斥,一次只能激活一个,默认激活手里剑攻击按钮
**攻击选择**:玩家通过点击对应攻击按钮直接选择武器,无需切换按钮 | 所有攻击都致命 | +| **绿衣(第一阶段)** | 拾取1个水晶玉 | 1(可抵挡1次普通攻击,被击中后立即变回红衣,所有强化效果清零) | 1.0 | 升级手里剑(体积增大) | **自动升级**:拾取水晶玉后,若当前使用手里剑攻击按钮,则手里剑自动升级为强化版,攻击按钮效果变化
**按钮互斥保持**:仍只能激活一个攻击按钮,使用忍者刀攻击按钮时,手里剑攻击按钮自动转为未激活状态 | 烟玉/火球仍秒杀 | +| **黄衣(第二阶段)** | 拾取2个水晶玉 | 1(可抵挡1次普通攻击,被击中后立即变回红衣,所有强化效果清零) | 1.5 | 升级手里剑 | **进一步自动强化**:再次拾取水晶玉后,升级手里剑性能自动提升(速度0.25秒/发),手里剑攻击按钮显示升级特效
**互斥机制不变**:保持两个攻击按钮互斥,点击任一攻击按钮激活对应武器,另一个自动转为未激活状态 | 移动速度过快反而容易误判位置,妖僧火球仍瞬杀 | + +#### 2.4.4 道具掉落规律 +- **点丸/术丸**:森林场景中,每击杀3个赤忍,随机出现1个点丸(攻击力+50%)或术丸(移动速度+30%)。 +- **水晶玉**:在森林关卡中,当画面上第12个忍者(不分青忍或赤忍)被击倒时,水晶玉出现在屏幕上方。**非随机出现**:这与"术丸"(杀3个红忍随机出)或"增丸"(固定关卡条件)不同,水晶玉的出现是完全确定性的,只要数清楚击杀数,就一定能拿到。**限时存在**:它出现后大约只有13-20秒的存续时间,若不及时捡起或画面卷轴移走,就会消失。 +- **卷物(魔笛)**:仅在城壁关卡的黑忍身上掉落,拾取后可秒杀全屏敌人,是通关关键道具。 +- **增丸**:在密道关卡中随机出现,拾取后增加一条命(永久有效)。 + +#### 2.4.5 敌人AI行为细节 +- **青忍**:基础敌人,远距离投掷十字镖,近距离刀斩,移动较慢但攻击频率稳定。 +- **赤忍(红忍)**: + - 移动速度较快(120px/秒) + - 投掷烟玉(攻击力是普通2倍,一击即死,无法格挡) + - 若玩家停留不前,会主动跳到玩家前方进行拦截 + - 击杀3个后可能掉落点丸/术丸 +- **妖僧(妖坊)**: + - 喷射直线火球,一击瞬杀(包括黄衣状态) + - 最佳对策:地面连续发射手里剑抢先击杀 + - 火球与玩家手里剑互不干扰,可通过火力压制 +- **黑忍**: + - 仅在城壁关卡出现 + - 打倒后掉落卷物(魔笛),若未拾取则不会再次出现 + - 体型较大,生命值较高(3点) + +#### 2.4.6 得分与成就系统设计 +- **武器击杀分差**: + - 刀斩击杀:基础分×2.0(奖励精准近战) + - 手里剑击杀:基础分×1.0(标准远程) + - 完美格挡反击击杀:基础分×3.0(高阶技巧) +- **连击奖励**:连续"刃接触"(刀剑互击)达到5次以上,触发1500分连击奖励 +- **无伤通关奖励**:全程不受伤害通关关卡,获得3倍基础分数 +- **时间挑战奖励**:在规定时间内通关,剩余时间按比例转换为额外分数 + +## 三、数值系统设计 + +### 3.1 基础数值框架 +#### 3.1.1 主角属性表 +| 属性 | 基础值 | 绿衣加成 | 黄衣加成 | 单位 | +|------|--------|---------|---------|------| +| 生命值 | 1 | 2 | 2 | 点 | +| 移动速度 | 100 | 100 | 150 | px/秒 | +| 攻击速度 | 0.3 | 0.25 | 0.25 | 秒/次 | +| 跳跃高度 | 250 | 250 | 300 | px | +| 冲刺距离 | 80 | 80 | 100 | px | + +#### 3.1.2 敌人属性表 +| 敌人类型 | 生命值 | 移动速度 | 攻击力 | 攻击间隔 | 掉落经验 | +|---------|--------|---------|--------|---------|---------| +| 青忍 | 1 | 80 | 1 | 2.0秒 | 10 | +| 赤忍(红忍) | 1 | 120 | 1 | 1.5秒 | 15 | +| 黑忍 | 2 | 100 | 2 | 2.0秒 | 25 | +| 妖坊(妖僧) | 1 | 60 | 999(秒杀) | 3.0秒 | 30 | +| 妖珠坊 | 3 | 70 | 999(秒杀) | 2.5秒 | 50 | + +### 3.2 关卡难度曲线 +#### 3.2.1 硬核难度(原作模式) +| 关卡 | 敌人数量 | BOSS血量 | 时间限制 | 推荐等级 | +|------|---------|---------|---------|---------| +| 1-1 森林初始 | 12 | 无 | 75秒 | Lv1 | +| 1-2 森林深处 | 18 | 无 | 85秒 | Lv1 | +| 1-3 妖坊战 | 20 | 3 | 100秒 | Lv2 | +| 1-4 密道 | 25 | 无 | 95秒 | Lv2 | +| 1-5 BOSS双幻坊 | 8 | 3 | 130秒 | Lv3 | + +## 四、关卡与BOSS设计 + +### 4.1 关卡流程设计(基于原作循环制) +原作采用3章循环制(青叶、红叶、雪),每章5关。微信版将精简为3大章共15关,保留核心循环体验。 + +#### 4.1.1 第一章:青叶之章 +| 关卡序号 | 场景名称 | 关卡类型 | 通关条件 | 核心挑战 | 推荐策略 | +|---------|---------|---------|---------|---------|---------| +| **1-1** | 初始森林 | 横向卷轴 | 打倒3个妖坊 | 忌乱跳,地面战斗优先 | 稳扎稳打,优先击杀远程敌人 | +| **1-2** | 森林深处 | 横向卷轴 | 打倒红妖珠坊 | 敌人密度增加,陷阱增多 | 利用树木掩护,逐个击破 | +| **1-3** | 抜け穴(洞穴/水路) | 左右卷轴 | 全灭10个青忍 | 水陆并行,移动受限 | 水中移动减速,优先清理陆地敌人 | +| **1-4** | 城壁(城墙) | 垂直卷轴 | 不断向上跳跃 | 赤忍干扰,寻找黑忍 | 找黑忍拿卷物(魔笛),可秒杀全屏 | +| **1-5** | 魔城内天守阁 | 室内多层 | 与双幻坊BOSS对战时,公主被青忍带走 | 妖僧驻守,楼梯狭窄 | 击败双幻坊BOSS,公主被青忍带走后进入下一章 | + +#### 4.1.2 第二章:红叶之章 +| 关卡序号 | 场景名称 | 关卡类型 | 通关条件 | 核心挑战 | 推荐策略 | +|---------|---------|---------|---------|---------|---------| +| **2-1** | 红叶森林 | 横向卷轴 | 打倒4个妖坊 | 敌人更密集,新增陷阱 | 利用红叶树木掩护,谨慎前进 | +| **2-2** | 红叶洞穴 | 左右卷轴 | 全灭12个青忍 | 水路更复杂 | 优先清理高处敌人 | +| **2-3** | 红叶城壁 | 垂直卷轴 | 抵达顶层 | 敌人干扰更强 | 快速向上移动,避免纠缠 | +| **2-4** | 红叶魔城 | 室内多层 | 抵达天守阁顶层 | 敌人配置升级 | 使用手里剑火力压制 | +| **2-5** | 红叶决战 | BOSS战 | 与雾雪之介BOSS对战时,公主被青忍带走 | 难度升级,公主被带走增加紧张感 | 击败雾雪之介BOSS,公主被青忍带走后进入下一章 | + +#### 4.1.3 第三章:雪之章 +| 关卡序号 | 场景名称 | 关卡类型 | 通关条件 | 核心挑战 | 推荐策略 | +|---------|---------|---------|---------|---------|---------| +| **3-1** | 雪原森林 | 横向卷轴 | 打倒5个妖坊 | 雪地减速,视野受限 | 缓慢推进,注意雪地陷阱 | +| **3-2** | 雪原洞穴 | 左右卷轴 | 全灭15个青忍 | 冰面滑行,控制困难 | 适应冰面物理,精准操作 | +| **3-3** | 雪原城壁 | 垂直卷轴 | 抵达冰封顶层 | 冰面攀爬难度大 | 小心滑落,逐步攀登 | +| **3-4** | 雪原魔城 | 室内多层 | 抵达冰封天守阁 | 冰雪环境敌人 | 利用环境优势 | +| **3-5** | 最终决战 | BOSS战 | 击败最终BOSS | 终极挑战 | 运用所有技巧 | + +### 4.2 BOSS战机制设计 +#### 4.2.1 BOSS通用机制 +1. **蝴蝶显形机制**:所有BOSS战必须先打落蝴蝶(使其变色),BOSS才会显形并受到攻击。 +2. **一击必杀**:BOSS显形后,只需一次有效攻击即可击败(还原原作设定)。 +3. **阶段转换**:BOSS血量每减少1/3转换攻击模式,增加战斗变化。 + +#### 4.2.2 各章BOSS设计 +| BOSS名称 | 所属章节 | 攻击模式 | 特殊机制 | 应对策略 | +|---------|---------|---------|---------|---------| +| **双幻坊** | 第一章 | 1.双人夹击 2.火球喷射 3.分身迷惑 | 双人协同攻击,火球无法格挡 | 1.优先打落蝴蝶 2.躲避夹击 3.火球间隙攻击 | +| **雾雪之介** | 第二章 | 1.二刀流近战 2.快速突刺 3.烟雾掩护 | 刀斩可被格挡,但连击速度快 | 1.完美格挡反击 2.保持距离 3.烟雾中谨慎移动 | +| **雪草妖四郎** | 第三章 | 1.高速移动 2.多重手里剑 3.终极火球 | 原型天草四郎,移动速度最快 | 1.预判移动轨迹 2.火力压制 3.躲避终极技 | + +#### 4.2.3 BOSS战难度调节 +- **轻度模式**:蝴蝶更容易命中,BOSS攻击间隔延长,攻击模式提示更明显。 +- **硬核模式**:蝴蝶移动更快,BOSS攻击频率提高,攻击模式随机性增强。 + +### 4.3 关卡场景互动元素 +#### 4.3.1 森林场景互动 +- **树木**:可作为掩护躲避远程攻击 +- **草丛**:隐藏玩家身影,暂时躲避敌人视线 +- **藤蔓**:可攀爬改变垂直位置 +- **陷阱**:地刺、落石等需要跳跃躲避 + +#### 4.3.2 城市场景互动 +- **砖墙**:可攀爬,但速度较慢 +- **窗户**:可破坏进入室内捷径 +- **旗帜**:可作为跳跃支点 +- **城墙垛口**:提供射击掩护 + +#### 4.3.3 室内场景互动 +- **楼梯**:上下楼切换,但移动受限 +- **绳索**:需要挥刀斩断才能通过 +- **屏风**:可破坏,可能隐藏道具 +- **灯笼**:可击落照明或作为陷阱 + + + +## 五、美术与音效设计 + +### 5.1 美术风格规范 + +#### 5.1.1 像素美术规格 +- **分辨率**:320×480(适配微信小游戏) +- **色彩深度**:16位色(65536色) +- **角色尺寸**:16×32像素(主角),16×16像素(普通敌人:青忍/赤忍),20×24像素(黑忍),18×20像素(妖坊),32×32像素及以上(BOSS,详见5.1.2) +- **动画帧率**:12fps(复古感),可切换24fps(流畅模式) + +#### 5.1.2 角色设计规范 + +**图像参考资源**: +- 主角三种形态:`images/影.png`(红衣、绿衣、黄衣并排展示) +- 敌人全阵容:`images/敌人.png`(青忍、赤忍、黑忍、妖坊、双幻坊、雾雪之介、雪草妖四郎并排展示) + +- **主角"影"**: + + | 形态 | 忍者服颜色 | 面罩 | 服饰细节 | 武器 | 动画帧数 | + |------|-----------|------|---------|------|---------| + | **红衣(初始)** | 深红色 | 黑色蒙面 | 基础忍者服,无额外装饰 | 手里剑(远程) | 站立(2帧),奔跑(4帧),跳跃(3帧),攻击(3帧) | + | **绿衣(第一阶段)** | 翠绿色 | 黑色蒙面 | 忍者服边缘带金色镶边,腰带加粗 | 升级手里剑(体积增大) | 同上,升级攻击特效(2帧) | + | **黄衣(第二阶段)** | 金黄色 | 黑色蒙面 | 忍者服带红色纹路,披风状下摆加长 | 强化手里剑(速度提升) | 同上,强化攻击特效(3帧) | + +- **敌人设计**: + + | 敌人类型 | 忍者服/服饰 | 体型 | 攻击姿势 | 动画帧数 | 像素尺寸 | + |---------|-----------|------|---------|---------|---------| + | **青忍** | 蓝色忍者服 | 标准体型 | 远距离投掷十字镖,近距离刀斩 | 站立(2帧),移动(3帧),攻击(2帧) | 16×16 | + | **赤忍(红忍)** | 暗红色忍者服 | 标准体型,略小于青忍 | 投掷烟玉,主动拦截跳跃 | 站立(2帧),移动(3帧),投掷(2帧),跳跃拦截(2帧) | 16×16 | + | **黑忍** | 深色忍者服 | 重装体型,明显大于普通敌人 | 重型近战攻击 | 站立(2帧),移动(3帧),攻击(3帧) | 20×24 | + | **妖坊(妖僧)** | 紫色僧袍 | 中等体型 | 喷射直线火球 | 站立(2帧),施法(2帧),火球特效(3帧) | 18×20 | + +- **BOSS设计**: + + | BOSS名称 | 忍者服/服饰 | 体型 | 攻击方式 | 特殊视觉 | 动画帧数 | 像素尺寸 | + |---------|-----------|------|---------|---------|---------|---------| + | **双幻坊** | 紫黑色僧袍(双人同形) | 大型,双人组合 | 双人夹击、火球喷射、分身迷惑 | 双人同步/异步动画,火球特效 | 站立(2帧),攻击(3帧),分身(2帧),火球(3帧) | 32×32×2 | + | **雾雪之介** | 深蓝色忍者服,白色披风 | 大型,修长体型 | 二刀流近战、快速突刺、烟雾掩护 | 刀光特效,烟雾粒子 | 站立(2帧),移动(4帧),二刀流(3帧),突刺(2帧),烟雾(2帧) | 32×36 | + | **雪草妖四郎** | 白金色调华丽忍者服 | 最大体型,威严感 | 高速移动、多重手里剑、终极火球 | 金色光晕,终极技全屏特效 | 站立(2帧),高速移动(4帧),手里剑(3帧),终极技(3帧) | 36×40 | + + **BOSS通用视觉特征**: + - 所有BOSS均配备蝴蝶显形动画(蝴蝶围绕→被打落变色→BOSS显形) + - BOSS体积明显大于普通敌人,视觉冲击力强 + - 每个BOSS拥有独特的配色方案和攻击特效,辨识度高 + +#### 5.1.3 场景设计 + +**图像参考资源**:`images/场景.png`(从上至下依次展示森林场景、城墙场景、魔城场景) + +**核心视觉特征**:游戏采用经典横版卷轴设计,场景从右至左自动推进,玩家控制主角在固定速度的背景中前进,营造紧迫感和冒险氛围。三种场景风格差异显著,通过色调、层次和交互元素区分,形成"自然→人造→超自然"的递进氛围。 + +- **森林场景(青叶/红叶/雪原变体)**:绿色调为主,春夏自然氛围 + + | 层级 | 滚动速度 | 视觉元素 | 像素规格 | 交互属性 | + |------|---------|---------|---------|---------| + | **远景层** | 慢速(1/8画面速度) | 远山轮廓+淡色云层,山顶微雾效果 | 山体80×120px,云团32×16px | 纯装饰,无碰撞 | + | **中景层** | 中速(1/4画面速度) | 粗壮树干+层叠树冠+低矮草丛,树干有明暗分面 | 树干8×40px,树冠24×20px,草丛16×8px | 树干可遮挡远程攻击(子弹被树干拦截),草丛可短暂隐藏身影 | + | **近景层** | 快速(1/2画面速度) | 泥土地面+地刺陷阱+落石区域+藤蔓 | 地面全宽×16px高,地刺8×8px,藤蔓4×32px | 地面碰撞体,地刺/落石造成伤害,藤蔓可攀爬 | + | **特效层** | 同近景 | 落叶粒子(红叶章节)、飘雪粒子(雪原章节) | 4×4px粒子 | 纯装饰 | + + **色调变体**: + - 青叶之章:翠绿色主调,明快通透 + - 红叶之章:橙红色主调,树冠变为红叶色,地面铺落叶 + - 雪原之章:蓝白色主调,地面覆雪(移速-15%),树冠积雪 + +- **城墙场景**:灰褐色调,垂直卷轴,人工建筑感 + + | 层级 | 滚动方向 | 视觉元素 | 像素规格 | 交互属性 | + |------|---------|---------|---------|---------| + | **远景层** | 缓慢上移 | 城堡远景剪影+夜空星辰 | 城堡轮廓64×96px | 纯装饰 | + | **中景层** | 同步上移 | 青砖墙纹理+木质横梁+城墙垛口 | 砖块8×4px纹理,横梁32×4px,垛口8×12px | 垛口提供射击掩护(可蹲伏躲避飞行物) | + | **近景层** | 同步上移 | 石质攀爬点+木质跳台+赤忍出没点 | 攀爬点8×16px,跳台24×6px | 攀爬点可抓握上移,跳台可站立 | + | **特效层** | 同近景 | 攀爬尘土粒子、风吹旗帜动画 | 2×2px尘土,8×12px旗帜 | 纯装饰,旗帜不可交互 | + + **视觉特征**:砖墙有清晰的砖缝纹理,每隔一段距离出现火把照明区域(暖色光圈,半径32px),城垛口呈锯齿状排列 + +- **魔城场景**:暗红紫色调,室内封闭氛围,压迫感 + + | 层级 | 滚动方式 | 视觉元素 | 像素规格 | 交互属性 | + |------|---------|---------|---------|---------| + | **远景层** | 视差左移 | 深色帷幕+暗色壁画+烛台 | 帷幕24×48px,烛台8×12px | 纯装饰,烛台微弱光效 | + | **中景层** | 同步左移 | 木质地板+石柱+屏风+楼梯 | 地板全宽×8px,石柱12×48px,屏风20×32px,楼梯16×24px | 石柱遮挡远程攻击,屏风可破坏(可能隐藏道具),楼梯上下层切换 | + | **近景层** | 同步左移 | 绳索+灯笼+敌人刷新点 | 绳索2×32px,灯笼8×10px | 绳索需挥刀斩断才能通过,灯笼可击落(照明消失或点燃陷阱) | + | **特效层** | 同近景 | 火把光晕脉动、烟雾粒子、BOSS显形光效 | 光晕16×16px,烟雾4×4px粒子 | 火把光晕照亮周围8px范围,烟雾干扰视线 | + + **视觉特征**:整体暗色调,仅靠火把/烛台提供局部照明,走廊狭窄,楼梯处视野受限;BOSS房间的照明比普通走廊稍亮,营造决战氛围 + +**场景通用技术规格**: +- **场景图块(Tile)尺寸**:8×8px基础图块,组合为16×16px逻辑图块 +- **场景宽度**:单场景横向约4096px(约128个逻辑图块宽),纵向固定480px +- **视差层数**:每种场景固定4层(远/中/近/特效),确保性能稳定 +- **调色板限制**:每场景独立调色板,不超过48色(含角色共用16色) + +### 5.2 音效设计规范 +#### 5.2.1 音效列表 +| 音效类型 | 文件名 | 时长 | 使用场景 | +|---------|--------|------|---------| +| 攻击音效 | attack.wav | 0.2s | 发射手里剑/挥刀 | +| 跳跃音效 | jump.wav | 0.3s | 蓄力跳跃 | +| 受伤音效 | hurt.wav | 0.5s | 受到攻击 | +| 拾取音效 | pickup.wav | 0.2s | 拾取道具 | +| 格挡音效 | parry.wav | 0.3s | 成功格挡 | +| BGM关卡1 | bgm_forest.mp3 | 2:00 | 森林关卡 | +| BGM关卡2 | bgm_castle.mp3 | 2:30 | 城墙关卡 | +| BGM关卡3 | bgm_final.mp3 | 3:00 | 魔城关卡 | +| BOSS战BGM | bgm_boss.mp3 | 2:00 | BOSS战斗 | + +#### 5.2.2 音频技术规格 +- **格式**:MP3(背景音乐),WAV(音效) +- **采样率**:44.1kHz +- **位深度**:16-bit +- **压缩率**:128kbps(MP3) +- **文件大小限制**:总音频包≤500KB + +## 六、技术实现方案 + +### 6.1 开发框架选择 +- **游戏引擎**:Cocos Creator 3.8.x + - 优势:微信小游戏原生支持,2D性能优秀,TypeScript开发 + - 社区资源丰富,文档齐全 +- **开发语言**:TypeScript +- **物理引擎**:内置Box2D简化版 +- **网络框架**:微信小游戏SDK + 自研轻量网络层 + +### 6.2 技术架构 +``` +游戏架构分层: +1. 表现层(UI/渲染与悬浮操作) + - **Canvas分层渲染器**:支持游戏场景层、悬浮UI层、特效层的独立渲染与混合 + - **悬浮UI系统**:专门为悬浮按钮设计的UI组件,支持动态透明度、触控优先响应和手势穿透 + - **粒子与特效系统**:独立于悬浮UI层的特效渲染,确保按钮视觉效果不干扰游戏内容 + - **物理感知触控事件分发与智能识别系统**:智能触控事件管理,确保悬浮按钮层触控优先,非按钮区域事件穿透到游戏层; + - **抛物线轨迹识别**:支持45°(右上)和135°(左上)方向识别,智能触发抛物线跳跃轨迹 + - **物理状态感知**:实时检测玩家物理状态(地面/空中),只有地面状态才允许跳跃操作 + - **组合操作识别**:支持跳跃攻击同时操作,在100ms内智能识别组合意图 + - **抛物线攻击组合**:支持45°/135°抛物线跳跃过程中同时攻击,实现抛物线轨迹中的攻击 + - **三合一操作支持**:支持移动+跳跃+攻击同时操作,实现全方位战斗 + - **双攻击按钮互斥管理**:智能管理两个攻击按钮(手里剑/忍者刀)的互斥状态,确保一次只能激活一个攻击按钮,点击一个按钮时另一个自动转为未激活状态 + - **自动升级事件处理**:拾取水晶玉后自动触发手里剑升级,无需玩家手动操作 +2. 逻辑层(游戏核心) + - 状态机(玩家/敌人状态) + - 碰撞检测系统 + - 技能管理系统 +3. 数据层(持久化) + - 本地存储(进度/设置) + - 缓存系统(资源管理) +4. 网络层(社交/广告) + - 微信API封装 + - 广告SDK集成 + - 排行榜系统 +``` + +### 6.3 性能优化方案 +#### 6.3.1 内存优化 +- **资源动态加载**:首包≤4MB,关卡资源按需加载 +- **对象池技术**:敌人、子弹、特效复用 +- **纹理图集**:合并小图,减少DrawCall +- **自动内存回收**:场景切换时清理未使用资源 + +#### 6.3.2 渲染优化 +- **批处理渲染**:相同材质的Sprite批量绘制 +- **视口裁剪**:只渲染可见区域内的对象 +- **LOD系统**:远景使用低精度资源 +- **帧率控制**:锁30fps(小游戏标准) + +#### 6.3.3 网络优化 +- **数据压缩**:JSON数据gzip压缩 +- **请求合并**:批量上报游戏数据 +- **本地缓存**:频繁访问数据本地存储 +- **降级策略**:网络异常时使用本地模式 + +### 6.4 兼容性适配 +- **微信版本**:支持基础库2.16.0+ +- **设备性能分级**: + - 高端机(A12+芯片):全特效,30fps + - 中端机(骁龙660+):中等特效,30fps + - 低端机(入门级):基础特效,25fps +- **屏幕适配**: + - 安全区域识别(iPhone刘海屏) + - 异形屏黑边处理 + - 横竖屏锁定(竖屏模式) + +## 七、开发里程碑 + +### 7.1 第一阶段:原型开发(4周) +- **第1周**:项目搭建+基础框架 + - Cocos Creator项目初始化 + - 基础渲染管线搭建 + - 输入控制系统 +- **第2周**:核心玩法实现 + - 主角移动+攻击系统 + - 基础敌人AI + - 碰撞检测系统 +- **第3周**:关卡系统 + - 关卡编辑器开发 + - 第一关完整实现 + - BOSS战原型 +- **第4周**:悬浮UI系统(参考《王者荣耀》设计) + - **悬浮操作UI**:虚拟摇杆、攻击按钮、跳跃按钮的悬浮图层实现 + - **悬浮UI交互系统**:动态透明度、触控优先响应、手势穿透等特性实现 + - **悬浮新手引导**:针对悬浮操作的新手引导系统 + - **悬浮UI设置**:按钮位置、大小、透明度的个性化设置功能 + +### 7.2 第二阶段:内容完善(4周) +- **第5周**:美术资源整合 + - 主角+敌人动画完成 + - 场景资源制作 + - 特效系统实现 +- **第6周**:音效系统 + - BGM+音效集成 + - 音频管理系统 + - 音量设置功能 +- **第7周**:完整关卡 + - 3大章15关制作完成 + - BOSS战平衡调整 + - 难度曲线优化 +- **第8周**:数值系统 + - 经验升级系统 + - 道具平衡调整 + - 成就系统 + +### 7.3 第三阶段:商业化集成(3周) +- **第9周**:广告系统 + - 激励视频集成 + - 插屏广告配置 + - Banner广告位 +- **第10周**:社交功能 + - 微信排行榜 + - 分享功能 + - 好友助力系统 +- **第11周**:测试优化 + - 性能测试优化 + - 兼容性测试 + - 用户体验测试 + +### 7.4 第四阶段:上线准备(1周) +- **第12周**:上线准备 + - 包体优化压缩 + - 提审材料准备 + - 运营后台搭建 + +## 八、测试计划 + +### 8.1 测试阶段划分 +#### 8.1.1 单元测试(开发期) +- **测试范围**:核心系统函数 +- **测试方法**:Jest单元测试框架 +- **覆盖率目标**:核心逻辑≥80% +- **执行频率**:每日构建时自动运行 + +#### 8.1.2 功能测试(Alpha阶段) +- **测试范围**:全部游戏功能 +- **测试用例**:200+测试用例 +- **缺陷管理**:禅道/Jira缺陷跟踪 +- **验收标准**:无阻塞性缺陷,功能完整 + +#### 8.1.3 兼容性测试(Beta阶段) +- **测试设备**:20+款主流手机 + - iPhone 8/11/12/13/14 + - 华为Mate/P系列 + - 小米数字/Redmi系列 + - OPPO/VIVO主流机型 +- **微信版本**:5+个历史版本 +- **网络环境**:4G/5G/WiFi切换测试 + +#### 8.1.4 性能测试(发布前) +- **帧率测试**:≥25fps(低端机) +- **内存测试**:峰值≤200MB +- **耗电测试**:1小时耗电≤15% +- **发热测试**:连续游戏30分钟温度≤42°C + +### 8.2 用户测试 +#### 8.2.1 内部测试(50人) +- **测试周期**:2周 +- **反馈收集**:问卷+访谈 +- **关键指标**: + - 新手留存率:≥60% + - 关卡通过率:1-5关≥40% + - 平均游戏时长:≥8分钟/次 + +#### 8.2.2 外部测试(500人) +- **测试渠道**:微信小游戏体验版 +- **测试周期**:1周 +- **数据监控**: + - 崩溃率:≤1% + - **物理感知悬浮操作性能指标**: + - 触控响应延迟:<50ms(悬浮按钮) + - 物理状态检测延迟:<50ms(地面/空中状态检测) + - 角度识别准确率:45°/135°方向识别准确率≥95% + - 视觉反馈延迟:<30ms(按钮按下到效果显示) + - 物理状态反馈延迟:<50ms(地面/空中状态变化反馈) + - 组合操作识别延迟:<100ms(跳跃攻击组合识别) + - 抛物线操作识别:支持45°(右上)、135°(左上)抛物线轨迹操作 + - 多点触控支持:3点以上同时操作,特别优化45°/135°方向+跳跃同时操作 + - 防误触与组合识别准确率:≥95% + - 物理限制执行准确率:空中状态跳跃禁用执行率≥99% + - 抛物线跳跃操作流畅度评分:≥4.2/5.0 + - 跳跃攻击操作流畅度评分:≥4.3/5.0 + - 总体操作流畅度评分:≥4.2/5.0 + - 难度满意度:硬核模式/轻度模式分开统计 + +### 8.3 安全测试 +- **代码安全**:静态代码扫描 +- **数据安全**:用户数据加密存储 +- **支付安全**:虚拟货币防作弊 +- **广告安全**:防激励视频刷量 + +## 九、运营与商业化 + +### 9.1 商业模式设计 +#### 9.1.1 广告变现策略 +| 广告类型 | 出现频率 | 触发条件 | eCPM预估 | 收入占比 | +|---------|---------|---------|---------|---------| +| 激励视频 | 3-5次/日 | 复活/双倍奖励 | ¥80-120 | 60% | +| 插屏广告 | 1次/2关 | 关卡结束/返回 | ¥40-60 | 25% | +| Banner广告 | 常驻显示 | 主菜单/商店 | ¥20-30 | 15% | + +#### 9.1.2 虚拟商品设计 +| 商品类型 | 价格(钻石) | 人民币 | 效果 | 购买限制 | +|---------|------------|--------|------|---------| +| 复活币 | 10 | ¥1 | 立即复活 | 每日5次 | +| 双倍卡 | 30 | ¥3 | 结算×2 | 每日3次 | +| 隐身衣 | 50 | ¥5 | 隐身15秒 | 每关1次 | +| 月卡 | 300 | ¥30 | 每日领取60钻 | 永久有效 | + +### 9.2 用户运营策略 +#### 9.2.1 新手期(0-3天) +- **目标**:完成教学,通过第1章 +- **策略**: + 1. 登录奖励:第1天10钻,第2天20钻,第3天30钻 + 2. 进度保护:前3关失败可免费复活1次 + 3. 社交引导:邀请好友获得复活币 + +#### 9.2.2 成长期(4-30天) +- **目标**:通过全部关卡,形成游戏习惯 +- **策略**: + 1. 每日任务:5个日常任务(60钻) + 2. 每周挑战:特殊规则关卡(100钻) + 3. 成就系统:收集类成就(皮肤/称号) + +#### 9.2.3 成熟期(30天+) +- **目标**:深度参与,高付费转化 +- **策略**: + 1. 赛季系统:每2个月新主题赛季 + 2. 排行榜竞赛:好友/全服排行榜 + 3. 公会系统:组队挑战特殊关卡 + +### 9.3 市场推广计划 +#### 9.3.1 上线初期(第1个月) +- **预热期**: + - 怀旧游戏社区预热(贴吧、NGA) + - 游戏媒体通稿发布 + - KOL体验评测(10-20人) +- **爆发期**: + - 微信朋友圈广告投放 + - 小游戏中心推荐位争取 + - 裂变活动:邀请好友得限定皮肤 + +#### 9.3.2 稳定期(2-6个月) +- **内容更新**: + - 每月新增1个特殊关卡 + - 每赛季新增1个BOSS + - 节日活动(春节/国庆特别版) +- **用户维护**: + - 核心玩家社群运营(QQ群/微信群) + - 玩家建议收集与反馈 + - 定期版本更新说明 + +### 9.4 数据监控体系 +#### 9.4.1 核心指标 +| 指标类别 | 具体指标 | 目标值 | 监控频率 | +|---------|---------|--------|---------| +| 用户规模 | DAU | 5万+ | 每日 | +| 留存率 | 次日留存 | 35%+ | 每日 | +| | 7日留存 | 15%+ | 每周 | +| 付费指标 | 付费率 | 3%+ | 每日 | +| | ARPU | ¥0.8+ | 每周 | +| 游戏表现 | 关卡通过率 | 1-5关≥40% | 每日 | +| | 平均时长 | ≥8分钟 | 每日 | + +#### 9.4.2 异常监控 +- **崩溃监控**:实时报警(崩溃率>1%) +- **性能监控**:帧率<20fps设备占比 +- **作弊监控**:异常高分/快速通关检测 +- **广告异常**:填充率<70%报警 + +## 十、风险评估与应对 + +### 10.1 技术风险 +| 风险点 | 概率 | 影响 | 应对措施 | +|-------|------|------|---------| +| 微信API变更 | 中 | 高 | 1. 接口抽象层设计 2. 定期更新适配 3. 降级方案备用 | +| 性能不达标 | 中 | 高 | 1. 多设备性能测试 2. 动态画质调节 3. 资源分级加载 | +| 兼容性问题 | 高 | 中 | 1. 主流设备全覆盖测试 2. 云测平台租用 3. 热更新修复机制 | +| 包体超标 | 中 | 高 | 1. 资源压缩优化 2. 分包加载策略 3. 定期清理无用资源 | + +### 10.2 内容风险 +| 风险点 | 概率 | 影响 | 应对措施 | +|-------|------|------|---------| +| 版权争议 | 低 | 极高 | 1. 美术完全原创 2. 名称避免侵权 3. 法律咨询备案 | +| 内容审核 | 中 | 高 | 1. 提前了解审核规则 2. 暴力元素弱化处理 3. 准备多个备选方案 | +| 数值不平衡 | 高 | 中 | 1. A/B测试验证 2. 动态数值调整 3. 玩家反馈快速响应 | +| 玩法单一 | 中 | 中 | 1. 赛季更新新玩法 2. 社交功能增强 3. UGC内容鼓励 | + +### 10.3 市场风险 +| 风险点 | 概率 | 影响 | 应对措施 | +|-------|------|------|---------| +| 竞品冲击 | 高 | 中 | 1. 差异化定位(怀旧+双模式) 2. 快速迭代响应 3. 社群运营增强粘性 | +| 用户获取成本高 | 高 | 高 | 1. 精细化投放策略 2. 裂变活动设计 3. 口碑营销投入 | +| 变现效率低 | 中 | 高 | 1. 多广告位测试 2. 虚拟商品优化 3. 用户付费习惯培养 | +| 政策风险 | 低 | 极高 | 1. 合规性自查 2. 版号申请准备 3. 海外市场备选 | + +### 10.4 运营风险 +| 风险点 | 概率 | 影响 | 应对措施 | +|-------|------|------|---------| +| 用户流失快 | 高 | 高 | 1. 新手体验优化 2. 留存活动设计 3. 召回机制建立 | +| 负面口碑 | 中 | 高 | 1. 客服快速响应 2. 社区舆情监控 3. 问题公开透明处理 | +| 数据安全 | 低 | 极高 | 1. 数据加密存储 2. 隐私政策合规 3. 第三方审计 | +| 团队稳定性 | 中 | 高 | 1. 核心人员备份 2. 文档规范化 3. 代码版本管理严格 | + +## 十一、成功指标与评估 + +### 11.1 阶段性目标 +#### 11.1.1 上线1个月 +- **用户规模**:DAU ≥ 3万 +- **留存表现**:次日留存 ≥ 30%,7日留存 ≥ 12% +- **收入表现**:月流水 ≥ ¥10万 +- **口碑评分**:微信评分 ≥ 4.2/5.0 + +#### 11.1.2 上线3个月 +- **用户规模**:DAU ≥ 5万(稳定期) +- **留存表现**:30日留存 ≥ 5% +- **收入表现**:月流水 ≥ ¥20万 +- **用户评价**:核心玩家社群活跃度 ≥ 50% + +#### 11.1.3 上线6个月 +- **用户规模**:累计用户 ≥ 50万 +- **收入表现**:累计流水 ≥ ¥100万 +- **运营健康度**:LTV > CAC 1.5倍 +- **市场地位**:动作类小游戏 Top 10 + +### 11.2 长期愿景 +- **IP打造**:形成自有"影之传说"IP +- **多平台扩展**:App Store/Google Play版本 +- **续作开发**:基于成功经验的系列化产品 +- **团队成长**:培养核心游戏开发团队 + +--- + +**文档版本**:v2.0 +**创建日期**:2026-05-03 +**更新记录**: +- v1.0:基础策划框架 +- v2.0:详细策划方案扩展,新增数值设计、技术方案、运营策略等内容 + +**适用对象**:项目团队全体成员、投资人、合作伙伴 + +**保密等级**:内部机密,未经许可不得外传 \ No newline at end of file diff --git a/doc/玩家体验.md b/doc/玩家体验.md new file mode 100644 index 0000000..79b94b5 --- /dev/null +++ b/doc/玩家体验.md @@ -0,0 +1,53 @@ +你提供的链接是天幻网“一命通关”专题站中关于 FC《影之传说》(The Legend of Kage) 的详细资料页。该页面由资深玩家(如火光、Magicaltour)整理,包含了极其硬核且精准的系统机制拆解、敌人行为逻辑、道具掉落规律以及高阶通关战术。 + +结合你的“微信小游戏复刻策划”需求,我从该页提取了以下核心设计与数值关键,可直接用于策划文档细化: + +1. 核心系统与原作“手感”还原(最重要) + +• 一击必死与防御机制:主角没有血条,触碰敌人攻击判定即死。但刀(A键)不仅能近战,还能格挡敌人的刀斩和手里剑(刃刃相消),但不能格挡赤忍的烟玉和妖僧的火球。这种“攻防一体”的按键设计是原作操作感的核心。 + +* 跳跃机制(硬核来源):主角跳跃极高且远,但起跳前有下蹲延迟,且空中无法调整轨迹。这导致跳跃时极易成为活靶子,原作建议“不准乱跳”,提倡地面战斗。 +• 衣服/状态分级: + + * 红衣(初始):一击死。 + ◦ 绿衣(1个水晶球):可承受1次普通攻击(刀/镖),手里剑变大。 + + ◦ 黄衣(2个水晶球):移动速度加快,但妖僧火球仍瞬杀(无无敌时间),且速度过快反而容易送死。 + +2. 道具与敌人AI细节(关卡设计参考) + +• 道具掉落规律: + + ◦ “点丸/术丸”:森场景每杀3个红忍,随机出1个。 + + ◦ “水晶玉”:击杀携带的敌人后掉落,倒地后在其上方出现。 + + * “卷物(魔笛)”:仅城壁关的黑忍掉落,秒杀全屏敌人。 +• 关键敌人行为: + + ◦ 青忍:远投十字镖,近身刀斩。 + + ◦ 赤忍(红忍):移动快,投烟玉(攻击力是普通2倍,一刀即死,无法格挡),若玩家停留不前会主动跳到前方。 + + * 妖僧(妖坊):喷直线火球,一击瞬杀(包括黄衣)。最佳对策是地面连发手里剑抢先击杀,火球与手里剑互不干扰。 + ◦ 黑忍(城壁关):仅FC版有,打倒掉卷物,不出则不复现。 + +3. 关卡流程与BOSS机制(场景脚本参考) + +原作共3章(青叶、红叶、雪),每章5关,循环制: +1. 森(森林):打倒3个妖坊 → 打倒红妖珠坊。最难关卡,忌乱跳。 +2. 抜け穴(洞穴/水路):左右卷轴,水陆并行,全灭10个青忍过关。 +3. 城壁(城墙):垂直卷轴,不断向上跳,赤忍干扰,找黑忍拿卷物。 +4. 魔城内:4层天守阁,上楼梯抵达顶层准备救雾姬时,发现公主已被青忍带走,妖僧驻守(为后续BOSS战铺垫)。 +5. 对决(BOSS):必须先打落蝴蝶(变色),BOSS才显形受击,一击必杀。 + * 青叶章BOSS:双幻坊(双人夹击+火球,难点)。 + ◦ 红叶章BOSS:雾雪之介(二刀流,可格挡)。 + + ◦ 雪章BOSS:雪草妖四郎(最快,原型天草四郎)。 + +4. 复刻策划启示 + +* 难度锚点:原作“一命通关”极难,核心在于不可控的跳跃轨迹和突然出现的远程火力(烟玉/火球)。微信版若做轻量化,建议保留“格挡”判定,但可优化跳跃空中可控性;若做硬核怀旧,则必须还原“起跳延迟”和“一击死”。 +* 得分/成就系统:原作有详细的武器击杀分差(刀斩是手里剑2倍)、连击奖励(连续刃接触1500分),适合设计复刻版的“高分榜”或“成就”。 + +这份资料几乎是FC版《影之传说》的逆向工程级攻略,对你的数值设计和动作帧设定非常有参考价值。 \ No newline at end of file diff --git a/images/场景.png b/images/场景.png new file mode 100644 index 0000000..a9837ee Binary files /dev/null and b/images/场景.png differ diff --git a/images/场景2.png b/images/场景2.png new file mode 100644 index 0000000..c9b5b73 Binary files /dev/null and b/images/场景2.png differ diff --git a/images/影.png b/images/影.png new file mode 100644 index 0000000..7111cf3 Binary files /dev/null and b/images/影.png differ diff --git a/images/敌人.png b/images/敌人.png new file mode 100644 index 0000000..7df87a6 Binary files /dev/null and b/images/敌人.png differ diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..94eeec3 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,32 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/tests'], + testMatch: ['**/*.test.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], + moduleNameMapper: { + '^cc$': '/tests/__mocks__/cc.ts', + '^@/(.*)$': '/assets/scripts/$1', + '^@common/(.*)$': '/assets/scripts/common/$1', + '^@data/(.*)$': '/assets/scripts/data/$1', + '^@logic/(.*)$': '/assets/scripts/logic/$1', + '^@ui/(.*)$': '/assets/scripts/ui/$1', + }, + collectCoverageFrom: [ + 'assets/scripts/common/**/*.ts', + 'assets/scripts/data/**/*.ts', + 'assets/scripts/logic/**/*.ts', + '!assets/scripts/**/*.d.ts', + ], + coverageThreshold: { + global: { + statements: 80, + branches: 75, + functions: 80, + lines: 80, + }, + }, + coverageReporters: ['text', 'lcov', 'html'], + verbose: true, +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..e4a20d3 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "kage-legend-mvp", + "version": "0.1.0", + "description": "Shadow Legend: Ninja Rescue Princess - MVP (Chapter 1, Landscape, WeChat Mini Game)", + "private": true, + "author": "KateLegend2 Team", + "license": "UNLICENSED", + "scripts": { + "lint": "eslint \"assets/scripts/**/*.ts\"", + "lint:fix": "eslint \"assets/scripts/**/*.ts\" --fix", + "format": "prettier --write \"assets/scripts/**/*.ts\"", + "test": "jest --config jest.config.js", + "test:watch": "jest --config jest.config.js --watch", + "test:coverage": "jest --config jest.config.js --coverage" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "^18.19.0", + "@typescript-eslint/eslint-plugin": "^6.21.0", + "@typescript-eslint/parser": "^6.21.0", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.1.3", + "jest": "^29.7.0", + "prettier": "^3.2.5", + "ts-jest": "^29.1.2", + "typescript": "5.3.3" + }, + "engines": { + "node": ">=16" + }, + "creator": { + "version": "3.8.8" + }, + "uuid": "f203b847-bad0-449b-a395-adf02e6185c4" +} diff --git a/scripts/gen_placeholder_assets.js b/scripts/gen_placeholder_assets.js new file mode 100644 index 0000000..91be234 --- /dev/null +++ b/scripts/gen_placeholder_assets.js @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * Placeholder asset generator. + * + * Produces minimal-but-valid binary files for every entry in + * `assets/resources/ASSETS.md`, so the Cocos Creator project can be opened + * and navigated end-to-end before the real art/audio pass. + * + * • PNG : 1×1 solid-color (distinct per category for visual debugging) + * • WAV : 0.1 s of silence, mono, 8 kHz, 8-bit PCM (~ 1 KB) + * • MP3 : minimal silent MP3 header frame + * + * Safe to re-run: files are only (re)written when their content differs or + * the path does not exist yet. + * + * Usage: + * node scripts/gen_placeholder_assets.js + */ + +const fs = require('fs'); +const path = require('path'); +const zlib = require('zlib'); + +// --------------------------------------------------------------------- // +// PNG builder (1×1 RGBA solid color, no external deps) // +// --------------------------------------------------------------------- // +function makeSolidPng(r, g, b, a = 255) { + const crcTable = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; + })(); + function crc32(buf) { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = crcTable[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; + } + function chunk(type, data) { + const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0); + const typeBuf = Buffer.from(type, 'ascii'); + const crcInput = Buffer.concat([typeBuf, data]); + const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(crcInput), 0); + return Buffer.concat([len, typeBuf, data, crc]); + } + + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(1, 0); // width + ihdr.writeUInt32BE(1, 4); // height + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type RGBA + ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0; + const raw = Buffer.from([0x00, r & 0xff, g & 0xff, b & 0xff, a & 0xff]); // filter byte + pixel + const idatData = zlib.deflateSync(raw); + return Buffer.concat([ + signature, + chunk('IHDR', ihdr), + chunk('IDAT', idatData), + chunk('IEND', Buffer.alloc(0)), + ]); +} + +// --------------------------------------------------------------------- // +// WAV builder (0.1s silence, mono 8 kHz, 8-bit PCM) // +// --------------------------------------------------------------------- // +function makeSilentWav(durationSec = 0.1) { + const sampleRate = 8000; + const numSamples = Math.floor(sampleRate * durationSec); + const dataSize = numSamples; // 1 byte per sample + const fileSize = 44 + dataSize - 8; + const buf = Buffer.alloc(44 + dataSize); + buf.write('RIFF', 0); + buf.writeUInt32LE(fileSize, 4); + buf.write('WAVE', 8); + buf.write('fmt ', 12); + buf.writeUInt32LE(16, 16); // subchunk1 size + buf.writeUInt16LE(1, 20); // PCM + buf.writeUInt16LE(1, 22); // mono + buf.writeUInt32LE(sampleRate, 24); + buf.writeUInt32LE(sampleRate, 28); // byteRate = sampleRate * 1 * 8/8 + buf.writeUInt16LE(1, 32); // blockAlign + buf.writeUInt16LE(8, 34); // bits per sample + buf.write('data', 36); + buf.writeUInt32LE(dataSize, 40); + buf.fill(128, 44); // unsigned silence = 128 + return buf; +} + +// --------------------------------------------------------------------- // +// MP3 builder (single silent MPEG-1 layer 3 frame, 8 kHz mono 8 kbps) // +// --------------------------------------------------------------------- // +// 13-byte ID3v2 empty tag + one 24-byte frame. Any real MP3 decoder will +// render it as ~0.07s of silence. +function makeSilentMp3() { + const id3 = Buffer.from([ + 0x49, 0x44, 0x33, // "ID3" + 0x03, 0x00, // v2.3 + 0x00, // flags + 0x00, 0x00, 0x00, 0x00, // size (0) + ]); + // MPEG-1 Layer III, 32 kbps, 32 kHz, mono, CRC off, no padding + // Frame header bytes chosen to be universally parseable. + const frame = Buffer.from([ + 0xff, 0xfb, 0x10, 0xc4, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, + ]); + return Buffer.concat([id3, frame]); +} + +// --------------------------------------------------------------------- // +// Spec table // +// --------------------------------------------------------------------- // +const ROOT = path.resolve(__dirname, '..', 'assets', 'resources'); + +// [relativePath, kind, colorOrNote] +const SPEC = [ + // 1. Protagonist — 3 color states + ['textures/characters/kage_red.png', 'png', [220, 60, 60]], + ['textures/characters/kage_green.png', 'png', [ 60, 200, 100]], + ['textures/characters/kage_yellow.png', 'png', [240, 210, 60]], + + // 2. Enemies + BOSS + ['textures/enemies/qing_ren.png', 'png', [ 80, 160, 220]], + ['textures/enemies/chi_ren.png', 'png', [200, 70, 70]], + ['textures/enemies/hei_ren.png', 'png', [ 40, 40, 50]], + ['textures/enemies/yao_fang.png', 'png', [180, 100, 200]], + ['textures/bosses/shuang_huan_fang.png', 'png', [120, 40, 140]], + ['textures/bosses/butterfly.png', 'png', [240, 240, 100]], + + // 3. Scenes — 3 themes × 4 layers + ['textures/scenes/forest/far.png', 'png', [ 30, 80, 50]], + ['textures/scenes/forest/mid.png', 'png', [ 50, 110, 70]], + ['textures/scenes/forest/near.png', 'png', [ 70, 140, 80]], + ['textures/scenes/forest/fx.png', 'png', [180, 220, 160]], + + ['textures/scenes/castle_wall/far.png', 'png', [ 60, 60, 80]], + ['textures/scenes/castle_wall/mid.png', 'png', [100, 100, 120]], + ['textures/scenes/castle_wall/near.png', 'png', [140, 140, 150]], + ['textures/scenes/castle_wall/fx.png', 'png', [220, 220, 200]], + + ['textures/scenes/demon_castle/far.png', 'png', [ 40, 20, 50]], + ['textures/scenes/demon_castle/mid.png', 'png', [ 70, 30, 80]], + ['textures/scenes/demon_castle/near.png', 'png', [110, 50, 120]], + ['textures/scenes/demon_castle/fx.png', 'png', [200, 120, 220]], + + // 4. Story illustrations + ['textures/story/ch1_page1_ninja.png', 'png', [ 50, 80, 140]], + ['textures/story/ch1_page2_princess.png', 'png', [220, 160, 180]], + ['textures/story/ch1_page3_depart.png', 'png', [180, 180, 110]], + + // 5. FX textures + ['textures/fx/leaf_particle.png', 'png', [110, 180, 70]], + ['textures/fx/jump_dust.png', 'png', [210, 210, 200]], + ['textures/fx/parry_spark.png', 'png', [255, 240, 130]], + + // 6. SFX WAVs + ['audio/sfx/attack.wav', 'wav'], + ['audio/sfx/jump.wav', 'wav'], + ['audio/sfx/hurt.wav', 'wav'], + ['audio/sfx/pickup.wav', 'wav'], + ['audio/sfx/parry.wav', 'wav'], + + // 7. BGM MP3s + ['audio/bgm/bgm_forest.mp3', 'mp3'], + ['audio/bgm/bgm_castle.mp3', 'mp3'], + ['audio/bgm/bgm_final.mp3', 'mp3'], + ['audio/bgm/bgm_boss.mp3', 'mp3'], + ['audio/bgm/bgm_story.mp3', 'mp3'], +]; + +// --------------------------------------------------------------------- // +// Writer // +// --------------------------------------------------------------------- // +function ensureDir(dir) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); +} +function writeIfDifferent(filePath, buf) { + if (fs.existsSync(filePath)) { + const existing = fs.readFileSync(filePath); + if (existing.length === buf.length && existing.equals(buf)) { + return 'unchanged'; + } + } + ensureDir(path.dirname(filePath)); + fs.writeFileSync(filePath, buf); + return 'wrote'; +} + +let wrote = 0; +let skipped = 0; +for (const entry of SPEC) { + const rel = entry[0]; + const kind = entry[1]; + const abs = path.join(ROOT, rel); + let buf; + switch (kind) { + case 'png': { + const [r, g, b] = entry[2]; + buf = makeSolidPng(r, g, b, 255); + break; + } + case 'wav': + buf = makeSilentWav(); + break; + case 'mp3': + buf = makeSilentMp3(); + break; + default: + throw new Error(`unknown kind: ${kind}`); + } + const action = writeIfDifferent(abs, buf); + if (action === 'wrote') { wrote++; console.log(` + ${rel}`); } + else { skipped++; } +} +console.log(`\nPlaceholder asset generation done: ${wrote} written, ${skipped} unchanged.`); diff --git a/settings/v2/packages/builder.json b/settings/v2/packages/builder.json new file mode 100644 index 0000000..9cad815 --- /dev/null +++ b/settings/v2/packages/builder.json @@ -0,0 +1,6 @@ +{ + "__version__": "1.3.9", + "textureCompressConfig": { + "genMipmaps": false + } +} diff --git a/settings/v2/packages/cocos-service.json b/settings/v2/packages/cocos-service.json new file mode 100644 index 0000000..71bc50c --- /dev/null +++ b/settings/v2/packages/cocos-service.json @@ -0,0 +1,23 @@ +{ + "__version__": "3.0.9", + "game": { + "name": "UNKNOW GAME", + "app_id": "UNKNOW", + "c_id": "0" + }, + "appConfigMaps": [ + { + "app_id": "UNKNOW", + "config_id": "f8b515" + } + ], + "configs": [ + { + "app_id": "UNKNOW", + "config_id": "f8b515", + "config_name": "Default", + "config_remarks": "", + "services": [] + } + ] +} diff --git a/settings/v2/packages/device.json b/settings/v2/packages/device.json new file mode 100644 index 0000000..70e599e --- /dev/null +++ b/settings/v2/packages/device.json @@ -0,0 +1,3 @@ +{ + "__version__": "1.0.1" +} diff --git a/settings/v2/packages/engine.json b/settings/v2/packages/engine.json new file mode 100644 index 0000000..d3eab47 --- /dev/null +++ b/settings/v2/packages/engine.json @@ -0,0 +1,3 @@ +{ + "__version__": "1.0.12" +} diff --git a/settings/v2/packages/information.json b/settings/v2/packages/information.json new file mode 100644 index 0000000..94848de --- /dev/null +++ b/settings/v2/packages/information.json @@ -0,0 +1,23 @@ +{ + "__version__": "1.0.1", + "information": { + "customSplash": { + "id": "customSplash", + "label": "customSplash", + "enable": false, + "customSplash": { + "complete": false, + "form": "https://creator-api.cocos.com/api/form/show?" + } + }, + "removeSplash": { + "id": "removeSplash", + "label": "removeSplash", + "enable": false, + "removeSplash": { + "complete": false, + "form": "https://creator-api.cocos.com/api/form/show?" + } + } + } +} diff --git a/settings/v2/packages/program.json b/settings/v2/packages/program.json new file mode 100644 index 0000000..916c1b2 --- /dev/null +++ b/settings/v2/packages/program.json @@ -0,0 +1,3 @@ +{ + "__version__": "1.0.4" +} diff --git a/settings/v2/packages/project.json b/settings/v2/packages/project.json new file mode 100644 index 0000000..b120c4c --- /dev/null +++ b/settings/v2/packages/project.json @@ -0,0 +1,39 @@ +{ + "designResolution": { + "width": 960, + "height": 540, + "fitHeight": true, + "fitWidth": false + }, + "engineVersion": "3.8.3", + "modules": { + "includeModules": [ + "2d", + "audio", + "physics-2d-box2d", + "tiled-map", + "tween", + "ui", + "profiler" + ] + }, + "packages": { + "engine": "3.8.3" + }, + "__version__": "1.0.6", + "fbx": { + "legacyFbxImporter": { + "visible": true + } + }, + "general": { + "designResolution": { + "width": 960, + "height": 540 + } + }, + "script": { + "preserveSymlinks": true + }, + "custom_joint_texture_layouts": [] +} diff --git a/tests/__mocks__/cc.ts b/tests/__mocks__/cc.ts new file mode 100644 index 0000000..cdadff0 --- /dev/null +++ b/tests/__mocks__/cc.ts @@ -0,0 +1,210 @@ +/** + * Lightweight `cc` module mock used by Jest to allow platform-agnostic + * modules (`common/*`, `data/*`, some `logic/*`) to be unit-tested without + * pulling in the full Cocos Creator engine. + * + * Anything that actually touches `cc.Node` / scene graph must **not** live + * in a module that is directly tested by Jest; put it behind a thin facade + * instead (see `common/StorageMgr.ts` which isolates `sys.localStorage`). + */ + +class FakeEventTarget { + private listeners = new Map void>>(); + + on(event: string, cb: (...args: unknown[]) => void): void { + const list = this.listeners.get(event) ?? []; + list.push(cb); + this.listeners.set(event, list); + } + + off(event: string, cb?: (...args: unknown[]) => void): void { + if (!cb) { + this.listeners.delete(event); + return; + } + const list = this.listeners.get(event); + if (!list) return; + this.listeners.set( + event, + list.filter((fn) => fn !== cb) + ); + } + + emit(event: string, ...args: unknown[]): void { + const list = this.listeners.get(event); + if (!list) return; + for (const fn of list) { + fn(...args); + } + } +} + +export const EventTarget = FakeEventTarget; + +export const _decorator = { + ccclass: (_name?: string) => (target: unknown) => target as any, + property: (_opts?: unknown) => (_target: unknown, _key: string) => {}, +}; + +/** + * Minimal stand-in for `cc.Node`. Only the surface actually exercised by + * Scene Entry components is modelled so unit tests stay deterministic. + */ +export class Node { + public name: string = ''; + public active: boolean = true; + public layer: number = 0; + + constructor(name?: string) { + this.name = name ?? ''; + } + + public addChild(_child: Node): void {} + public on(_ev: string, _cb: (...args: unknown[]) => void, _target?: unknown): void {} + public off(_ev: string, _cb?: (...args: unknown[]) => void): void {} + public getComponent(_ctor: new (...args: unknown[]) => T): T | null { return null; } + public addComponent(_ctor: new (...args: unknown[]) => T): T { return {} as T; } + public setPosition(..._args: unknown[]): void {} + + public static EventType = { + TOUCH_START: 'touch-start', + TOUCH_MOVE: 'touch-move', + TOUCH_END: 'touch-end', + TOUCH_CANCEL: 'touch-cancel', + }; +} + +/** + * Minimal stand-in for `cc.Component`. Provides `.node` so Scene Entry TS + * types resolve. Runtime behaviour is injected by the Cocos Creator editor. + */ +export class Component { + public node: Node = new Node(); + public enabled: boolean = true; +} +export const director = { + addPersistRootNode: (..._args: unknown[]) => {}, + loadScene: (..._args: unknown[]) => {}, +}; +export const view = { + setDesignResolutionSize: (..._args: unknown[]) => {}, +}; +export const screen = { + orientation: 0, +}; +export const game = { + frameRate: 60, +}; +export const sys = { + localStorage: { + _store: new Map(), + getItem(key: string): string | null { + return (sys.localStorage as any)._store.get(key) ?? null; + }, + setItem(key: string, value: string): void { + (sys.localStorage as any)._store.set(key, value); + }, + removeItem(key: string): void { + (sys.localStorage as any)._store.delete(key); + }, + clear(): void { + (sys.localStorage as any)._store.clear(); + }, + }, +}; +export const settings = {}; +export const Settings = {}; +export const profiler = {}; + +// --------------------------------------------------------------------- +// Additional stubs for types imported by Scene Entry components. All the +// methods below are NO-OPs; the Cocos Creator editor injects the real +// implementations at runtime. Having them declared here keeps the TS +// language server happy and lets Jest exercise scene-entry unit tests. +// --------------------------------------------------------------------- + +export class Color { + public r: number; + public g: number; + public b: number; + public a: number; + constructor(r: number = 255, g: number = 255, b: number = 255, a: number = 255) { + this.r = r; this.g = g; this.b = b; this.a = a; + } + public static WHITE = new Color(255, 255, 255, 255); + public static BLACK = new Color(0, 0, 0, 255); +} + +export class Vec3 { + public x: number; + public y: number; + public z: number; + constructor(x: number = 0, y: number = 0, z: number = 0) { + this.x = x; this.y = y; this.z = z; + } +} + +export class UITransform { + public width: number = 0; + public height: number = 0; + public setContentSize(_w: number, _h: number): void {} +} + +export class Label { + public string: string = ''; + public fontSize: number = 24; + public lineHeight: number = 28; + public color: Color = Color.WHITE; + public horizontalAlign: number = 0; + public verticalAlign: number = 0; + public useSystemFont: boolean = true; +} + +export class Button { + public transition: number = 0; + public target: Node | null = null; + public zoomScale: number = 1; + + public static Transition = { NONE: 0, COLOR: 1, SPRITE: 2, SCALE: 3 }; +} + +export class Graphics { + public fillColor: Color = Color.WHITE; + public strokeColor: Color = Color.BLACK; + public lineWidth: number = 1; + public rect(_x: number, _y: number, _w: number, _h: number): void {} + public fill(): void {} + public stroke(): void {} +} + +export class Sprite { + public spriteFrame: unknown = null; + public type: number = 0; + public sizeMode: number = 0; + public static Type = { SIMPLE: 0, SLICED: 1, TILED: 2, FILLED: 3 }; + public static SizeMode = { CUSTOM: 0, TRIMMED: 1, RAW: 2 }; +} + +export class SpriteFrame { + public texture: unknown = null; +} + +export class Texture2D { + public image: unknown = null; + public static PixelFormat = { RGBA8888: 35 }; +} + +export class ImageAsset { + constructor(_opts?: unknown) {} +} + +export class JsonAsset { + public json: unknown = null; +} + +export class Canvas {} +export class EventTouch {} + +export const resources = { + load: (_path: string, _type: unknown, _cb: (err: Error | null, asset: unknown) => void): void => {}, +}; diff --git a/tests/common/Constants.test.ts b/tests/common/Constants.test.ts new file mode 100644 index 0000000..29fcc9e --- /dev/null +++ b/tests/common/Constants.test.ts @@ -0,0 +1,71 @@ +import { + DESIGN_WIDTH, + DESIGN_HEIGHT, + TARGET_FPS, + MOVE_SPEED, + PlayerColorState, + JUMP_HEIGHT_STANDARD, + JUMP_HEIGHT_CHARGED, + JUMP_HEIGHT_YELLOW, + JUMP_PREPARE_DELAY_MS, + PARABOLIC_ANGLE_RIGHT, + PARABOLIC_ANGLE_LEFT, + SHURIKEN_INTERVAL_BASE, + SHURIKEN_INTERVAL_UPGRADED, + SWORD_INTERVAL, + PERF_TOUCH_RESPONSE_MAX_MS, + PERF_COMBO_RECOGNITION_MAX_MS, + STORAGE_KEY, +} from '@common/Constants'; + +describe('Constants — requirement baseline values', () => { + it('uses 960x540 landscape baseline (req tech-stack)', () => { + expect(DESIGN_WIDTH).toBe(960); + expect(DESIGN_HEIGHT).toBe(540); + expect(DESIGN_WIDTH / DESIGN_HEIGHT).toBeCloseTo(16 / 9, 3); + }); + + it('locks target frame rate at 30 fps (req 18.1-18.3)', () => { + expect(TARGET_FPS).toBe(30); + }); + + it('assigns 100/100/150 px/s move speeds per color state (req 5.1-5.2)', () => { + expect(MOVE_SPEED[PlayerColorState.Red]).toBe(100); + expect(MOVE_SPEED[PlayerColorState.Green]).toBe(100); + expect(MOVE_SPEED[PlayerColorState.Yellow]).toBe(150); + }); + + it('uses 250/375/300 jump heights (req 2.2-2.3)', () => { + expect(JUMP_HEIGHT_STANDARD).toBe(250); + expect(JUMP_HEIGHT_CHARGED).toBe(375); + expect(JUMP_HEIGHT_YELLOW).toBe(300); + }); + + it('reserves ~150ms crouch delay before leaving ground (req 2.8)', () => { + expect(JUMP_PREPARE_DELAY_MS).toBe(150); + }); + + it('parabolic angles anchored at 45° and 135° (req 2.5)', () => { + expect(PARABOLIC_ANGLE_RIGHT).toBe(45); + expect(PARABOLIC_ANGLE_LEFT).toBe(135); + }); + + it('weapon intervals conform to 0.3/0.25/0.5s (req 3.4/3.6)', () => { + expect(SHURIKEN_INTERVAL_BASE).toBe(0.3); + expect(SHURIKEN_INTERVAL_UPGRADED).toBe(0.25); + expect(SWORD_INTERVAL).toBe(0.5); + }); + + it('enforces <50ms touch and <100ms combo KPI thresholds (req 20.1/20.4)', () => { + expect(PERF_TOUCH_RESPONSE_MAX_MS).toBe(50); + expect(PERF_COMBO_RECOGNITION_MAX_MS).toBe(100); + }); + + it('defines all storage keys required by persistence (req 17 & 19.5)', () => { + expect(STORAGE_KEY.LevelUnlock).toBeDefined(); + expect(STORAGE_KEY.ControlLayout).toBeDefined(); + expect(STORAGE_KEY.AudioVolume).toBeDefined(); + expect(STORAGE_KEY.TutorialDone).toBeDefined(); + expect(STORAGE_KEY.StoryIntroSeen).toBeDefined(); + }); +}); diff --git a/tests/common/EventBus.test.ts b/tests/common/EventBus.test.ts new file mode 100644 index 0000000..d6f71b6 --- /dev/null +++ b/tests/common/EventBus.test.ts @@ -0,0 +1,98 @@ +import { EventBus } from '@common/EventBus'; + +describe('EventBus', () => { + let bus: EventBus; + + beforeEach(() => { + bus = new EventBus(); + }); + + it('delivers emitted payload to on() subscribers', () => { + const spy = jest.fn(); + bus.on('hit', spy); + bus.emit('hit', 42); + expect(spy).toHaveBeenCalledWith(42); + }); + + it('ignores duplicate subscriptions of the same handler', () => { + const spy = jest.fn(); + bus.on('hit', spy); + bus.on('hit', spy); + bus.emit('hit', 1); + expect(spy).toHaveBeenCalledTimes(1); + expect(bus.listenerCount('hit')).toBe(1); + }); + + it('fires once() handlers exactly one time', () => { + const spy = jest.fn(); + bus.once('hit', spy); + bus.emit('hit', 1); + bus.emit('hit', 2); + expect(spy).toHaveBeenCalledTimes(1); + expect(bus.listenerCount('hit')).toBe(0); + }); + + it('off(event, fn) removes only that handler', () => { + const a = jest.fn(); + const b = jest.fn(); + bus.on('hit', a); + bus.on('hit', b); + bus.off('hit', a); + bus.emit('hit', 1); + expect(a).not.toHaveBeenCalled(); + expect(b).toHaveBeenCalledTimes(1); + }); + + it('off(event) clears all handlers of that event', () => { + const a = jest.fn(); + const b = jest.fn(); + bus.on('hit', a); + bus.on('hit', b); + bus.off('hit'); + bus.emit('hit', 1); + expect(a).not.toHaveBeenCalled(); + expect(b).not.toHaveBeenCalled(); + expect(bus.listenerCount('hit')).toBe(0); + }); + + it('isolates exceptions — one bad listener does not break fan-out', () => { + const err = jest.fn(); + bus.setErrorHook(err); + const good = jest.fn(); + bus.on('hit', () => { + throw new Error('boom'); + }); + bus.on('hit', good); + bus.emit('hit', 1); + expect(good).toHaveBeenCalledTimes(1); + expect(err).toHaveBeenCalledTimes(1); + expect(err.mock.calls[0][0]).toBe('hit'); + }); + + it('emit on an unknown event is a no-op', () => { + expect(() => bus.emit('ghost', 1)).not.toThrow(); + }); + + it('clear() drops every handler of every event', () => { + bus.on('a', () => {}); + bus.on('b', () => {}); + bus.clear(); + expect(bus.listenerCount('a')).toBe(0); + expect(bus.listenerCount('b')).toBe(0); + }); + + it('supports off for a non-existent handler without error', () => { + const spy = jest.fn(); + bus.off('hit', spy); + expect(bus.listenerCount('hit')).toBe(0); + }); + + it('handles a once-handler unsubscribing itself during emit', () => { + const calls: number[] = []; + bus.once('tick', (n) => calls.push(n)); + bus.on('tick', (n) => calls.push(n * 10)); + bus.emit('tick', 1); + bus.emit('tick', 2); + expect(calls).toEqual([1, 10, 20]); + }); +}); diff --git a/tests/common/Logger.test.ts b/tests/common/Logger.test.ts new file mode 100644 index 0000000..6166dd8 --- /dev/null +++ b/tests/common/Logger.test.ts @@ -0,0 +1,78 @@ +import { Logger, LogLevel } from '@common/Logger'; + +describe('Logger — leveled logging', () => { + let logger: Logger; + let sink: jest.Mock; + + beforeEach(() => { + logger = new Logger(); + sink = jest.fn(); + logger.setSink(sink); + }); + + it('suppresses messages below the threshold', () => { + logger.setLevel(LogLevel.Warn); + logger.debug('mod', 'hidden'); + logger.info('mod', 'hidden'); + logger.warn('mod', 'shown'); + logger.error('mod', 'shown'); + expect(sink).toHaveBeenCalledTimes(2); + }); + + it('forwards module + message + rest args to the sink', () => { + logger.setLevel(LogLevel.Debug); + logger.info('Pool', 'size', 5); + expect(sink).toHaveBeenCalledWith(LogLevel.Info, 'Pool', 'size', 5); + }); + + it('Silent level disables all output', () => { + logger.setLevel(LogLevel.Silent); + logger.error('mod', 'ignored'); + expect(sink).not.toHaveBeenCalled(); + }); +}); + +describe('Logger — performance metrics', () => { + let logger: Logger; + + beforeEach(() => { + logger = new Logger(); + }); + + it('records samples under a metric name', () => { + for (const v of [10, 20, 30, 40, 50]) { + logger.metric({ name: 'touch_ms', value: v }); + } + const agg = logger.aggregate('touch_ms'); + expect(agg).toBeDefined(); + expect(agg!.count).toBe(5); + expect(agg!.min).toBe(10); + expect(agg!.max).toBe(50); + expect(agg!.avg).toBeCloseTo(30); + expect(agg!.p50).toBe(30); + expect(agg!.p95).toBe(50); + }); + + it('returns undefined aggregate for unknown metric', () => { + expect(logger.aggregate('ghost')).toBeUndefined(); + }); + + it('timerStart/timerEnd records elapsed time', () => { + logger.timerStart('frame', 1000); + const elapsed = logger.timerEnd('frame', 1016); + expect(elapsed).toBe(16); + expect(logger.aggregate('frame')!.avg).toBe(16); + }); + + it('timerEnd without start returns undefined', () => { + expect(logger.timerEnd('never', 100)).toBeUndefined(); + }); + + it('resetMetrics clears all samples and running timers', () => { + logger.metric({ name: 'a', value: 1 }); + logger.timerStart('b', 0); + logger.resetMetrics(); + expect(logger.aggregate('a')).toBeUndefined(); + expect(logger.timerEnd('b', 10)).toBeUndefined(); + }); +}); diff --git a/tests/common/ObjectPool.test.ts b/tests/common/ObjectPool.test.ts new file mode 100644 index 0000000..936305c --- /dev/null +++ b/tests/common/ObjectPool.test.ts @@ -0,0 +1,88 @@ +import { ObjectPool } from '@common/ObjectPool'; + +interface Bullet { + x: number; + alive: boolean; +} + +const makeBullet = (): Bullet => ({ x: 0, alive: false }); +const resetBullet = (b: Bullet): void => { + b.x = 0; + b.alive = false; +}; + +describe('ObjectPool', () => { + it('creates new instances when the free list is empty', () => { + const pool = new ObjectPool({ factory: makeBullet }); + const a = pool.acquire(); + const b = pool.acquire(); + expect(a).not.toBe(b); + expect(pool.stats().created).toBe(2); + expect(pool.borrowedCount).toBe(2); + expect(pool.freeCount).toBe(0); + }); + + it('reuses instances after release', () => { + const pool = new ObjectPool({ factory: makeBullet, resetter: resetBullet }); + const a = pool.acquire(); + a.x = 100; + a.alive = true; + pool.release(a); + expect(a.x).toBe(0); + expect(a.alive).toBe(false); + const b = pool.acquire(); + expect(b).toBe(a); + expect(pool.stats().recycled).toBe(1); + }); + + it('pre-allocates instances when preAlloc is set', () => { + const pool = new ObjectPool({ factory: makeBullet, preAlloc: 5 }); + expect(pool.freeCount).toBe(5); + expect(pool.stats().created).toBe(5); + }); + + it('discards excess releases when over maxSize', () => { + const pool = new ObjectPool({ factory: makeBullet, maxSize: 2 }); + const a = pool.acquire(); + const b = pool.acquire(); + const c = pool.acquire(); + pool.release(a); + pool.release(b); + pool.release(c); // should be dropped + expect(pool.freeCount).toBe(2); + }); + + it('fires onDoubleRelease and ignores double-release', () => { + const spy = jest.fn(); + const pool = new ObjectPool({ + factory: makeBullet, + onDoubleRelease: spy, + }); + const a = pool.acquire(); + pool.release(a); + pool.release(a); + expect(spy).toHaveBeenCalledTimes(1); + expect(pool.freeCount).toBe(1); + }); + + it('drain() clears both free and borrowed', () => { + const pool = new ObjectPool({ factory: makeBullet, preAlloc: 3 }); + pool.acquire(); + pool.drain(); + expect(pool.freeCount).toBe(0); + expect(pool.borrowedCount).toBe(0); + }); + + it('stats track acquired / recycled totals', () => { + const pool = new ObjectPool({ factory: makeBullet }); + const a = pool.acquire(); + const b = pool.acquire(); + pool.release(a); + pool.release(b); + pool.acquire(); + const s = pool.stats(); + expect(s.acquired).toBe(3); + expect(s.recycled).toBe(2); + expect(s.created).toBe(2); + }); +}); diff --git a/tests/common/PerfMonitor.test.ts b/tests/common/PerfMonitor.test.ts new file mode 100644 index 0000000..a8a5823 --- /dev/null +++ b/tests/common/PerfMonitor.test.ts @@ -0,0 +1,110 @@ +import { Logger } from '@common/Logger'; +import { CORE_PERF_THRESHOLDS, PerfMonitor } from '@common/PerfMonitor'; +import { + PERF_TOUCH_RESPONSE_MAX_MS, + PERF_COMBO_RECOGNITION_MAX_MS, + MAX_FIRST_PACKAGE_BYTES, + MAX_AUDIO_BUNDLE_BYTES, + MAX_MEMORY_PEAK_BYTES, +} from '@common/Constants'; + +describe('PerfMonitor — threshold catalog (req 18 & 20)', () => { + it('includes every KPI threshold called out in requirements 20.1-20.5', () => { + const names = CORE_PERF_THRESHOLDS.map((t) => t.metric); + expect(names).toContain('input/touchStart'); + expect(names).toContain('jump/state_toggle_ms'); + expect(names).toContain('input/combo_recognition_ms'); + expect(names).toContain('input/parabolic_accuracy'); + expect(names).toContain('jump/air_jump_block_rate'); + }); +}); + +describe('PerfMonitor — pass/fail evaluation', () => { + function seedPassing(logger: Logger): void { + for (let i = 0; i < 100; i++) { + logger.metric({ name: 'input/touchStart', value: 20 }); + logger.metric({ name: 'jump/state_toggle_ms', value: 25 }); + logger.metric({ name: 'input/combo_recognition_ms', value: 40 }); + } + logger.metric({ name: 'input/parabolic_accuracy', value: 0.97 }); + logger.metric({ name: 'jump/air_jump_block_rate', value: 0.995 }); + } + + it('passes when all metrics are within budget', () => { + const logger = new Logger(); + seedPassing(logger); + const monitor = new PerfMonitor(logger); + const report = monitor.collectReport(); + expect(report.allPassing).toBe(true); + }); + + it('fails when no samples exist for a threshold', () => { + const monitor = new PerfMonitor(new Logger()); + const report = monitor.collectReport(); + expect(report.allPassing).toBe(false); + expect(report.checks[0].reason).toMatch(/no samples/); + }); + + it('fails when p95 latency exceeds the limit', () => { + const logger = new Logger(); + seedPassing(logger); + // Push a huge batch of slow touches so p95 exceeds budget. + for (let i = 0; i < 200; i++) { + logger.metric({ name: 'input/touchStart', value: PERF_TOUCH_RESPONSE_MAX_MS + 50 }); + } + const report = new PerfMonitor(logger).collectReport(); + const touchCheck = report.checks.find((c) => c.threshold.metric === 'input/touchStart')!; + expect(touchCheck.passing).toBe(false); + }); + + it('fails when parabolic accuracy drops below 95%', () => { + const logger = new Logger(); + seedPassing(logger); + logger.resetMetrics(); + logger.metric({ name: 'input/touchStart', value: 20 }); + logger.metric({ name: 'jump/state_toggle_ms', value: 20 }); + logger.metric({ name: 'input/combo_recognition_ms', value: 20 }); + logger.metric({ name: 'input/parabolic_accuracy', value: 0.8 }); + logger.metric({ name: 'jump/air_jump_block_rate', value: 0.999 }); + const report = new PerfMonitor(logger).collectReport(); + expect(report.allPassing).toBe(false); + }); + + it('evaluates build-size budget when provided', () => { + const logger = new Logger(); + logger.metric({ name: 'input/touchStart', value: 10 }); + logger.metric({ name: 'jump/state_toggle_ms', value: 10 }); + logger.metric({ name: 'input/combo_recognition_ms', value: 10 }); + logger.metric({ name: 'input/parabolic_accuracy', value: 1 }); + logger.metric({ name: 'jump/air_jump_block_rate', value: 1 }); + const monitor = new PerfMonitor(logger); + + const okReport = monitor.collectReport({ + firstPackageBytes: MAX_FIRST_PACKAGE_BYTES - 1, + audioBundleBytes: MAX_AUDIO_BUNDLE_BYTES - 1, + memoryPeakBytes: MAX_MEMORY_PEAK_BYTES - 1, + }); + expect(okReport.sizeBudgetPassing).toBe(true); + expect(okReport.allPassing).toBe(true); + + const badReport = monitor.collectReport({ + firstPackageBytes: MAX_FIRST_PACKAGE_BYTES + 1, + audioBundleBytes: MAX_AUDIO_BUNDLE_BYTES + 1, + memoryPeakBytes: MAX_MEMORY_PEAK_BYTES + 1, + }); + expect(badReport.sizeBudgetPassing).toBe(false); + expect(badReport.allPassing).toBe(false); + }); + + it('honours the <= comparator at exactly the boundary', () => { + const logger = new Logger(); + logger.metric({ name: 'input/touchStart', value: PERF_TOUCH_RESPONSE_MAX_MS }); + logger.metric({ name: 'jump/state_toggle_ms', value: 10 }); + logger.metric({ name: 'input/combo_recognition_ms', value: PERF_COMBO_RECOGNITION_MAX_MS }); + logger.metric({ name: 'input/parabolic_accuracy', value: 1 }); + logger.metric({ name: 'jump/air_jump_block_rate', value: 1 }); + const report = new PerfMonitor(logger).collectReport(); + const touch = report.checks.find((c) => c.threshold.metric === 'input/touchStart')!; + expect(touch.passing).toBe(true); + }); +}); diff --git a/tests/common/StorageMgr.test.ts b/tests/common/StorageMgr.test.ts new file mode 100644 index 0000000..da0a1cc --- /dev/null +++ b/tests/common/StorageMgr.test.ts @@ -0,0 +1,71 @@ +import { StorageMgr, IStorageDriver } from '@common/StorageMgr'; + +function makeMemoryDriver(): IStorageDriver { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? (m.get(k) as string) : null), + setItem: (k, v) => { + m.set(k, v); + }, + removeItem: (k) => { + m.delete(k); + }, + }; +} + +describe('StorageMgr', () => { + it('returns default when key is missing', () => { + const sm = new StorageMgr(makeMemoryDriver()); + expect(sm.get('nope', { v: 1 })).toEqual({ v: 1 }); + }); + + it('round-trips structured values via JSON', () => { + const sm = new StorageMgr(makeMemoryDriver()); + const layout = { jump: { x: 100, y: 40 }, shuriken: { x: 820, y: 40 } }; + sm.set('layout', layout); + expect(sm.get('layout', null)).toEqual(layout); + }); + + it('returns default when value is malformed JSON (req 17.6)', () => { + const broken: IStorageDriver = { + getItem: () => '{not json', + setItem: () => {}, + removeItem: () => {}, + }; + const sm = new StorageMgr(broken); + expect(sm.get('x', 'fallback')).toBe('fallback'); + }); + + it('does not throw when the driver throws (req 17.6)', () => { + const exploding: IStorageDriver = { + getItem: () => { + throw new Error('I/O error'); + }, + setItem: () => { + throw new Error('I/O error'); + }, + removeItem: () => {}, + }; + const sm = new StorageMgr(exploding); + expect(() => sm.get('x', 'ok')).not.toThrow(); + expect(sm.get('x', 'ok')).toBe('ok'); + expect(() => sm.set('x', 'value')).not.toThrow(); + }); + + it('remove() deletes the key', () => { + const sm = new StorageMgr(makeMemoryDriver()); + sm.set('k', 123); + sm.remove('k'); + expect(sm.get('k', -1)).toBe(-1); + }); + + it('setDriver swaps the underlying driver', () => { + const sm = new StorageMgr(makeMemoryDriver()); + sm.set('k', 1); + const fresh = makeMemoryDriver(); + sm.setDriver(fresh); + expect(sm.get('k', 0)).toBe(0); + sm.set('k', 2); + expect(sm.get('k', 0)).toBe(2); + }); +}); diff --git a/tests/common/TimeMgr.test.ts b/tests/common/TimeMgr.test.ts new file mode 100644 index 0000000..3019ee3 --- /dev/null +++ b/tests/common/TimeMgr.test.ts @@ -0,0 +1,58 @@ +import { TimeMgr } from '@common/TimeMgr'; + +describe('TimeMgr', () => { + let tm: TimeMgr; + beforeEach(() => { + tm = new TimeMgr(); + }); + + it('advances both clocks at timeScale 1', () => { + tm.update(1); + tm.update(0.5); + expect(tm.gameTime).toBeCloseTo(1.5); + expect(tm.realTime).toBeCloseTo(1.5); + }); + + it('freezes gameTime when paused but keeps realTime ticking', () => { + tm.pause(); + tm.update(1); + expect(tm.gameTime).toBe(0); + expect(tm.realTime).toBe(1); + tm.resume(); + tm.update(0.5); + expect(tm.gameTime).toBeCloseTo(0.5); + expect(tm.realTime).toBeCloseTo(1.5); + }); + + it('honors timeScale for slow-mo', () => { + tm.setTimeScale(0.5); + tm.update(2); + expect(tm.gameTime).toBeCloseTo(1); + expect(tm.realTime).toBe(2); + }); + + it('clamps negative timeScale to 0', () => { + tm.setTimeScale(-3); + expect(tm.timeScale).toBe(0); + tm.update(10); + expect(tm.gameTime).toBe(0); + }); + + it('scaledDelta() reflects pause and timeScale', () => { + tm.setTimeScale(2); + expect(tm.scaledDelta(0.1)).toBeCloseTo(0.2); + tm.pause(); + expect(tm.scaledDelta(0.1)).toBe(0); + }); + + it('reset() zeros everything', () => { + tm.update(1); + tm.pause(); + tm.setTimeScale(0.2); + tm.reset(); + expect(tm.gameTime).toBe(0); + expect(tm.realTime).toBe(0); + expect(tm.timeScale).toBe(1); + expect(tm.paused).toBe(false); + }); +}); diff --git a/tests/data/ConfigMgr.test.ts b/tests/data/ConfigMgr.test.ts new file mode 100644 index 0000000..438d324 --- /dev/null +++ b/tests/data/ConfigMgr.test.ts @@ -0,0 +1,124 @@ +import { ConfigMgr, MapJsonLoader } from '@data/ConfigMgr'; +import { EnemyType, ItemType, WeaponType } from '@data/Interfaces'; + +// Import the real JSON delivered by task 2.1 — if these files are malformed +// the test suite will catch it on CI before any Cocos editor run. +import enemies from '../../assets/resources/configs/enemies.json'; +import items from '../../assets/resources/configs/items.json'; +import weapons from '../../assets/resources/configs/weapons.json'; +import levels from '../../assets/resources/configs/levels.json'; +import bosses from '../../assets/resources/configs/bosses.json'; +import stories from '../../assets/resources/configs/stories.json'; + +function makeLoader(overrides: Partial> = {}) { + const base: Record = { + 'configs/enemies': enemies, + 'configs/items': items, + 'configs/weapons': weapons, + 'configs/levels': levels, + 'configs/bosses': bosses, + 'configs/stories': stories, + }; + return new MapJsonLoader({ ...base, ...overrides }); +} + +describe('ConfigMgr — happy path with delivered JSON', () => { + it('loads and validates the chapter-1 bundle', async () => { + const mgr = new ConfigMgr(makeLoader()); + const bundle = await mgr.load(); + expect(bundle.enemies.length).toBe(4); + expect(bundle.items.length).toBe(5); + expect(bundle.weapons.length).toBe(2); + expect(bundle.levels.length).toBe(5); + expect(bundle.bosses.length).toBe(1); + expect(bundle.stories.length).toBe(1); + }); + + it('resolves every enemy, item, weapon, level, boss, and story by id', async () => { + const mgr = new ConfigMgr(makeLoader()); + await mgr.load(); + expect(mgr.enemy(EnemyType.QingRen).displayName).toBe('青忍'); + expect(mgr.item(ItemType.CrystalJade).displayName).toBe('水晶玉'); + expect(mgr.weapon(WeaponType.NinjaSword).canParry).toBe(true); + expect(mgr.level('1-5').objective.kind).toBe('defeat_boss'); + expect(mgr.boss('shuang_huan_fang').phases.length).toBe(3); +expect(mgr.story('chapter_1_intro').pages.length).toBe(3); + }); + + it('throws when accessed before load()', () => { + const mgr = new ConfigMgr(makeLoader()); + expect(() => mgr.enemy(EnemyType.QingRen)).toThrow(/load\(\)/); + }); +}); + +describe('ConfigMgr — validation rejects malformed configs', () => { + it('rejects a config bundle that contains the forbidden "casual" token (req 13.6)', async () => { + const polluted = JSON.parse(JSON.stringify(levels)); + polluted[0].displayName = 'casual'; // inject disallowed token + const mgr = new ConfigMgr(makeLoader({ 'configs/levels': polluted })); + await expect(mgr.load()).rejects.toThrow(/casual/); + }); + + it('rejects an enemy entry missing required fields', async () => { + const bad = [{ id: 'qing_ren' }]; + const mgr = new ConfigMgr(makeLoader({ 'configs/enemies': bad })); + await expect(mgr.load()).rejects.toThrow(/missing field/); + }); + + it('rejects an enemy with an unknown EnemyType id', async () => { + const bad = JSON.parse(JSON.stringify(enemies)); + bad[0].id = 'not_a_real_enemy'; + const mgr = new ConfigMgr(makeLoader({ 'configs/enemies': bad })); + await expect(mgr.load()).rejects.toThrow(/EnemyType/); + }); + + it('rejects a level referencing an unknown boss', async () => { + const bad = JSON.parse(JSON.stringify(levels)); + bad[4].objective.bossId = 'ghost_boss'; + const mgr = new ConfigMgr(makeLoader({ 'configs/levels': bad })); + await expect(mgr.load()).rejects.toThrow(/unknown boss/); + }); + + it('rejects a level spawn referencing an unknown enemy', async () => { + const bad = JSON.parse(JSON.stringify(levels)); + bad[0].enemySpawns[0].type = 'white_ninja'; + const mgr = new ConfigMgr(makeLoader({ 'configs/levels': bad })); + await expect(mgr.load()).rejects.toThrow(/unknown enemy/); + }); + + it('rejects boss phases that are not monotonically descending', async () => { + const bad = JSON.parse(JSON.stringify(bosses)); + bad[0].phases = [ + { hpThreshold: 0.33, mode: 'a', actionIntervalSec: 1 }, + { hpThreshold: 1.0, mode: 'b', actionIntervalSec: 1 }, + ]; + const mgr = new ConfigMgr(makeLoader({ 'configs/bosses': bad })); + await expect(mgr.load()).rejects.toThrow(/descending hpThreshold/); + }); + + it('rejects a story with fewer than 3 pages (req 19.2)', async () => { + const bad = JSON.parse(JSON.stringify(stories)); + bad[0].pages = bad[0].pages.slice(0, 2); + const mgr = new ConfigMgr(makeLoader({ 'configs/stories': bad })); + await expect(mgr.load()).rejects.toThrow(/≥3 pages/); + }); + + it('rejects a story whose maxDurationSec exceeds the 30s budget (req 19.1)', async () => { + const bad = JSON.parse(JSON.stringify(stories)); + bad[0].maxDurationSec = 45; + const mgr = new ConfigMgr(makeLoader({ 'configs/stories': bad })); + await expect(mgr.load()).rejects.toThrow(/30s budget/); + }); + + it('rejects a story with non-contiguous page indices', async () => { + const bad = JSON.parse(JSON.stringify(stories)); + bad[0].pages[1].index = 5; + const mgr = new ConfigMgr(makeLoader({ 'configs/stories': bad })); + await expect(mgr.load()).rejects.toThrow(/contiguous/); + }); + + it('rejects an empty enemies list', async () => { + const mgr = new ConfigMgr(makeLoader({ 'configs/enemies': [] })); + await expect(mgr.load()).rejects.toThrow(/enemies list is empty/); + }); +}); diff --git a/tests/logic/AttackController.test.ts b/tests/logic/AttackController.test.ts new file mode 100644 index 0000000..e2c49c0 --- /dev/null +++ b/tests/logic/AttackController.test.ts @@ -0,0 +1,106 @@ +import { AttackController, IJumpStateProvider } from '@logic/AttackController'; +import { WeaponType } from '@data/Interfaces'; +import { PlayerColorState } from '@common/Constants'; + +function makeJumpState(ts?: number): IJumpStateProvider { + return { + lastJumpPressTs: () => ts, + isGrounded: () => ts === undefined, + }; +} + +describe('AttackController — mutual exclusion (req 3.1-3.3)', () => { + it('first-pressed weapon wins when both buttons go down together', () => { + const ac = new AttackController(); + ac.press(WeaponType.Shuriken, 10); + ac.press(WeaponType.NinjaSword, 11); + expect(ac.getActive()).toBe(WeaponType.Shuriken); + }); + + it('releasing the active weapon transfers activation to the still-held one', () => { + const ac = new AttackController(); + ac.press(WeaponType.Shuriken, 0); + ac.press(WeaponType.NinjaSword, 10); + ac.release(WeaponType.Shuriken); + expect(ac.getActive()).toBe(WeaponType.NinjaSword); + }); + + it('releasing the only pressed weapon deactivates everything', () => { + const ac = new AttackController(); + ac.press(WeaponType.Shuriken, 0); + ac.release(WeaponType.Shuriken); + expect(ac.getActive()).toBe('none'); + }); +}); + +describe('AttackController — firing intervals (req 3.4, 3.6)', () => { + it('shuriken fires every 300ms for red/green, 250ms for yellow', () => { + const ac = new AttackController(); + ac.press(WeaponType.Shuriken, 0); + expect(ac.tick(0, PlayerColorState.Red).length).toBe(1); + expect(ac.tick(299, PlayerColorState.Red).length).toBe(0); + expect(ac.tick(300, PlayerColorState.Red).length).toBe(1); + + const fast = new AttackController(); + fast.press(WeaponType.Shuriken, 0); + fast.tick(0, PlayerColorState.Yellow); + expect(fast.tick(249, PlayerColorState.Yellow).length).toBe(0); + expect(fast.tick(250, PlayerColorState.Yellow).length).toBe(1); + }); + + it('sword fires every 500ms', () => { + const ac = new AttackController(); + ac.press(WeaponType.NinjaSword, 0); + ac.tick(0, PlayerColorState.Red); + expect(ac.tick(499, PlayerColorState.Red).length).toBe(0); + expect(ac.tick(500, PlayerColorState.Red).length).toBe(1); + }); + + it('shuriken burst index caps at SHURIKEN_BURST_MAX', () => { + const ac = new AttackController(); + ac.press(WeaponType.Shuriken, 0); + const indexes: number[] = []; + for (let i = 0; i <= 600; i += 300) { + const fires = ac.tick(i, PlayerColorState.Red); + if (fires.length) indexes.push(fires[0].burstIndex); + } + expect(indexes).toEqual([1, 2, 3]); + // One more attempt must still cap at 3 not 4. + const more = ac.tick(900, PlayerColorState.Red); + expect(more[0].burstIndex).toBe(3); + }); +}); + +describe('AttackController — combo window (req 4.1)', () => { + it('comboWithJump is true when jump timestamp is within 100ms', () => { + const ac = new AttackController(makeJumpState(95)); + ac.press(WeaponType.Shuriken, 100); + const fires = ac.tick(100, PlayerColorState.Red); + expect(fires[0].comboWithJump).toBe(true); + }); + + it('comboWithJump is false when jump was pressed >100ms ago', () => { + const ac = new AttackController(makeJumpState(0)); + ac.press(WeaponType.Shuriken, 200); + const fires = ac.tick(200, PlayerColorState.Red); + expect(fires[0].comboWithJump).toBe(false); + }); + + it('comboWithJump is false when the player has not jumped yet', () => { + const ac = new AttackController(makeJumpState(undefined)); + ac.press(WeaponType.Shuriken, 0); + const fires = ac.tick(0, PlayerColorState.Red); + expect(fires[0].comboWithJump).toBe(false); + }); +}); + +describe('AttackController — reset()', () => { + it('clears active / pressed / cooldowns', () => { + const ac = new AttackController(); + ac.press(WeaponType.Shuriken, 0); + ac.tick(0, PlayerColorState.Red); + ac.reset(); + expect(ac.getActive()).toBe('none'); + expect(ac.isPressed(WeaponType.Shuriken)).toBe(false); + }); +}); diff --git a/tests/logic/BossSettlement.test.ts b/tests/logic/BossSettlement.test.ts new file mode 100644 index 0000000..8c0e79d --- /dev/null +++ b/tests/logic/BossSettlement.test.ts @@ -0,0 +1,126 @@ +import { BossController } from '@logic/BossController'; +import { BANNED_RESCUE_SEQUENCE, ChapterSettlement } from '@logic/ChapterSettlement'; +import { IBossConfig } from '@data/Interfaces'; + +const bossCfg: IBossConfig = { + id: 'shuang_huan_fang', + displayName: '双幻坊', + hp: 3, + butterflyReveal: true, + princessCutsceneAtHpRatio: 0.5, + phases: [ + { hpThreshold: 1.0, mode: 'pair_pincer', actionIntervalSec: 2.2 }, + { hpThreshold: 0.66, mode: 'fireball_spread', actionIntervalSec: 1.8 }, + { hpThreshold: 0.33, mode: 'clone_confuse', actionIntervalSec: 1.4 }, + ], +}; + +describe('BossController — butterfly reveal (req 9.1-9.3)', () => { + it('body hits are ignored until butterfly is hit', () => { + const boss = new BossController(bossCfg); + expect(boss.onBodyHit()).toEqual([]); + expect(boss.currentHp).toBe(3); + }); + + it('butterfly hit emits reveal event once', () => { + const boss = new BossController(bossCfg); + expect(boss.onButterflyHit()).toEqual([{ kind: 'butterfly_revealed' }]); + expect(boss.onButterflyHit()).toEqual([]); // second hit is a no-op + expect(boss.isButterflyRevealed).toBe(true); + }); + + it('body hits after reveal decrement HP one at a time', () => { + const boss = new BossController(bossCfg); + boss.onButterflyHit(); + boss.onBodyHit(); + expect(boss.currentHp).toBe(2); + }); +}); + +describe('BossController — phase transitions (req 9.4)', () => { + it('HP drop to 2/3 triggers phase_changed to fireball_spread', () => { + const boss = new BossController(bossCfg); + boss.onButterflyHit(); + const events = boss.onBodyHit(); // 3 → 2 (ratio 0.66) + expect(events.some((e) => e.kind === 'phase_changed' && e.phase === 'fireball_spread')).toBe(true); + }); + + it('HP drop to 1/3 triggers clone_confuse', () => { + const boss = new BossController(bossCfg); + boss.onButterflyHit(); + boss.onBodyHit(); + const events = boss.onBodyHit(); // 2 → 1 (ratio 0.33) + expect(events.some((e) => e.kind === 'phase_changed' && e.phase === 'clone_confuse')).toBe(true); + }); +}); + +describe('BossController — princess cutscene + death (req 8.6, 14.1)', () => { + it('emits princess_taken_cutscene when HP reaches 1/2', () => { + const boss = new BossController(bossCfg); + boss.onButterflyHit(); + const events = boss.onBodyHit(); // 3 → 2 → ratio 0.66 (> 0.5, no cutscene yet) + expect(events.some((e) => e.kind === 'princess_taken_cutscene')).toBe(false); + const events2 = boss.onBodyHit(); // 2 → 1 → ratio 0.33 (< 0.5) + expect(events2.some((e) => e.kind === 'princess_taken_cutscene')).toBe(true); + }); + + it('emits boss_killed on final hit and marks isDead', () => { + const boss = new BossController(bossCfg); + boss.onButterflyHit(); + boss.onBodyHit(); + boss.onBodyHit(); + const events = boss.onBodyHit(); + expect(events.some((e) => e.kind === 'boss_killed')).toBe(true); + expect(boss.isDead).toBe(true); + }); + + it('further body hits after death are no-ops', () => { + const boss = new BossController(bossCfg); + boss.onButterflyHit(); + boss.onBodyHit(); + boss.onBodyHit(); + boss.onBodyHit(); + expect(boss.onBodyHit()).toEqual([]); + }); +}); + +describe('ChapterSettlement — rope-cut ban (req 14.5)', () => { + it('assertCutsceneAllowed throws on every banned id', () => { + const cs = new ChapterSettlement({ + totalScore: 0, + stageScore: 0, + comboCount: 0, + flawless: true, + remainingTimeSec: 0, + }); + for (const bad of BANNED_RESCUE_SEQUENCE) { + expect(() => cs.assertCutsceneAllowed(bad)).toThrow(/banned/); + } + }); + + it('allows the legitimate cutscene ids', () => { + const cs = new ChapterSettlement({ + totalScore: 0, + stageScore: 0, + comboCount: 0, + flawless: true, + remainingTimeSec: 0, + }); + expect(() => cs.assertCutsceneAllowed('princess_taken')).not.toThrow(); + expect(() => cs.assertCutsceneAllowed('boss_killed_freeze')).not.toThrow(); + expect(() => cs.assertCutsceneAllowed('settlement_screen')).not.toThrow(); + }); + + it('build() returns the "princess taken" closing line, not a rescue one', () => { + const cs = new ChapterSettlement({ + totalScore: 1000, + stageScore: 500, + comboCount: 2, + flawless: true, + remainingTimeSec: 10, + }); + const r = cs.build(); + expect(r.closingLine).toMatch(/公主被带走/); + expect(r.closingLine).not.toMatch(/救/); + }); +}); diff --git a/tests/logic/Chapter1Levels.test.ts b/tests/logic/Chapter1Levels.test.ts new file mode 100644 index 0000000..d49ad65 --- /dev/null +++ b/tests/logic/Chapter1Levels.test.ts @@ -0,0 +1,94 @@ +import { ConfigMgr, MapJsonLoader } from '@data/ConfigMgr'; +import { LevelMgr } from '@logic/LevelMgr'; +import { cameraFromLevel } from '@logic/CameraScroller'; +import { EnemyType } from '@data/Interfaces'; + +// Reuse delivered JSON. +import enemies from '../../assets/resources/configs/enemies.json'; +import items from '../../assets/resources/configs/items.json'; +import weapons from '../../assets/resources/configs/weapons.json'; +import levels from '../../assets/resources/configs/levels.json'; +import bosses from '../../assets/resources/configs/bosses.json'; +import stories from '../../assets/resources/configs/stories.json'; + +async function loadBundle() { + const mgr = new ConfigMgr( + new MapJsonLoader({ + 'configs/enemies': enemies, + 'configs/items': items, + 'configs/weapons': weapons, + 'configs/levels': levels, + 'configs/bosses': bosses, + 'configs/stories': stories, + }) + ); + await mgr.load(); + return mgr; +} + +describe('Chapter-1 levels — JSON × LevelMgr integration (task 7.2)', () => { + it('exposes all 5 levels (1-1 … 1-5)', async () => { + const mgr = await loadBundle(); + for (const id of ['1-1', '1-2', '1-3', '1-4', '1-5']) { + expect(mgr.level(id)).toBeDefined(); + } + }); + + it('1-1 requires killing 3 妖坊 within 75s (req 8.1)', async () => { + const mgr = await loadBundle(); + const lv = mgr.level('1-1'); + expect(lv.timeLimitSec).toBe(75); + expect(lv.objective).toEqual({ kind: 'kill_count', enemy: EnemyType.YaoFang, count: 3 }); + }); + + it('1-3 is a bi-directional cave stage with 10 青忍 objective (req 8.3)', async () => { + const mgr = await loadBundle(); + const lv = mgr.level('1-3'); + expect(lv.scrollDirection).toBe('horizontal_bi'); + expect(lv.objective).toEqual({ kind: 'kill_count', enemy: EnemyType.QingRen, count: 10 }); + }); + + it('1-4 is a vertical castle-wall stage with reach_top objective (req 8.4)', async () => { + const mgr = await loadBundle(); + const lv = mgr.level('1-4'); + expect(lv.scrollDirection).toBe('vertical'); + expect(lv.objective).toEqual({ kind: 'reach_top' }); + }); + + it('1-5 is a defeat-boss objective pointing at 双幻坊 (req 8.5)', async () => { + const mgr = await loadBundle(); + const lv = mgr.level('1-5'); + expect(lv.objective.kind).toBe('defeat_boss'); + expect(lv.objective.bossId).toBe('shuang_huan_fang'); + }); + + it('CameraScroller instantiates cleanly from each chapter-1 level', async () => { + const mgr = await loadBundle(); + for (const id of ['1-1', '1-2', '1-3', '1-4', '1-5']) { + const cam = cameraFromLevel(mgr.level(id)); + expect(cam.offsetX).toBe(0); + expect(cam.offsetY).toBe(0); + } + }); + + it('LevelMgr drives every level to victory via its configured objective', async () => { + const mgr = await loadBundle(); + for (const id of ['1-1', '1-2', '1-3', '1-4', '1-5']) { + const lv = new LevelMgr(mgr.level(id)); + switch (lv.level.objective.kind) { + case 'kill_count': + for (let k = 0; k < (lv.level.objective.count ?? 0); k++) { + lv.onEnemyKilled(lv.level.objective.enemy!); + } + break; + case 'reach_top': + lv.onReachedTop(); + break; + case 'defeat_boss': + lv.onBossKilled(); + break; + } + expect(lv.tick(0.016)).toBe('victory'); + } + }); +}); diff --git a/tests/logic/DamageSystem.test.ts b/tests/logic/DamageSystem.test.ts new file mode 100644 index 0000000..8813b67 --- /dev/null +++ b/tests/logic/DamageSystem.test.ts @@ -0,0 +1,86 @@ +import { DamageSystem, FIREBALL_KILL_RADIUS, SMOKE_KILL_RADIUS } from '@logic/DamageSystem'; +import { PlayerStateMachine } from '@logic/PlayerStateMachine'; +import { PlayerColorState } from '@common/Constants'; + +function setup() { + const psm = new PlayerStateMachine(); + return { psm, ds: new DamageSystem(psm) }; +} + +describe('DamageSystem — fireball distance gate (req 10.4)', () => { + it('misses when distance exceeds FIREBALL_KILL_RADIUS', () => { + const { ds } = setup(); + const r = ds.applyToPlayer({ + attackType: 'fireball', + attackerX: 0, + attackerY: 0, + victimX: FIREBALL_KILL_RADIUS + 10, + victimY: 0, + }); + expect(r).toBeNull(); + }); + + it('kills when within radius regardless of color', () => { + const { psm, ds } = setup(); + psm.pickupCrystalJade(); + psm.pickupCrystalJade(); + const r = ds.applyToPlayer({ + attackType: 'fireball', + attackerX: 0, + attackerY: 0, + victimX: 50, + victimY: 0, + }); + expect(r!.kind).toBe('died'); + expect(psm.color).toBe(PlayerColorState.Red); + }); +}); + +describe('DamageSystem — smoke bomb distance gate (req 10.5)', () => { + it('misses when distance exceeds SMOKE_KILL_RADIUS', () => { + const { ds } = setup(); + const r = ds.applyToPlayer({ + attackType: 'smoke_bomb', + attackerX: 0, + attackerY: 0, + victimX: SMOKE_KILL_RADIUS + 1, + victimY: 0, + }); + expect(r).toBeNull(); + }); + + it('kills when within radius', () => { + const { ds } = setup(); + const r = ds.applyToPlayer({ + attackType: 'smoke_bomb', + attackerX: 0, + attackerY: 0, + victimX: 40, + victimY: 0, + }); + expect(r!.kind).toBe('died'); + }); +}); + +describe('DamageSystem — precedence (req 10.3)', () => { + it('i-frames beat fireball distance check', () => { + const { psm, ds } = setup(); + psm.takeHit('shuriken'); // die → consumes a life and starts i-frames + const r = ds.applyToPlayer({ + attackType: 'fireball', + attackerX: 0, + attackerY: 0, + victimX: 10, + victimY: 0, + }); + expect(r).toEqual({ kind: 'no_effect', reason: 'iframe' }); + }); +}); + +describe('DamageSystem — applyToEnemy helper', () => { + it('reduces HP and floors at 0', () => { + const { ds } = setup(); + expect(ds.applyToEnemy(3, 2)).toBe(1); + expect(ds.applyToEnemy(1, 5)).toBe(0); + }); +}); diff --git a/tests/logic/DropSystem.test.ts b/tests/logic/DropSystem.test.ts new file mode 100644 index 0000000..37d6bbd --- /dev/null +++ b/tests/logic/DropSystem.test.ts @@ -0,0 +1,67 @@ +import { DropSystem } from '@logic/DropSystem'; +import { EnemyType, ItemType } from '@data/Interfaces'; + +describe('DropSystem — crystal jade deterministic rule (req 7.1)', () => { + it('spawns a crystal jade on exactly the 12th kill', () => { + const ds = new DropSystem({ random: () => 1 }); + let crystalEvents = 0; + for (let i = 1; i <= 24; i++) { + const drops = ds.onEnemyKilled(EnemyType.YaoFang, { x: 100, y: 0 }); + if (drops.some((d) => d.item === ItemType.CrystalJade)) crystalEvents++; + } + expect(crystalEvents).toBe(2); + }); + + it('spawns the crystal above the kill point', () => { + const ds = new DropSystem({ random: () => 1 }); + for (let i = 0; i < 11; i++) ds.onEnemyKilled(EnemyType.QingRen, { x: 0, y: 0 }); + const drops = ds.onEnemyKilled(EnemyType.QingRen, { x: 300, y: 20 }); + const crystal = drops.find((d) => d.item === ItemType.CrystalJade); + expect(crystal!.y).toBeGreaterThan(20); + }); +}); + +describe('DropSystem — Chi Ren consecutive rule (req 7.3)', () => { + it('drops dian_wan or shu_wan on the 3rd consecutive Chi Ren kill if RNG <0.5', () => { + const ds = new DropSystem({ + dianShuWanProbability: 0.5, + random: (() => { + // probability check then which-item check both pass + const vals = [0.1, 0.2]; + let i = 0; + return () => vals[i++ % vals.length]; + })(), + }); + ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 }); + ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 }); + const drops = ds.onEnemyKilled(EnemyType.ChiRen, { x: 50, y: 10 }); + expect(drops.find((d) => d.item === ItemType.DianWan)).toBeDefined(); + }); + + it('does not drop when RNG fails probability', () => { + const ds = new DropSystem({ dianShuWanProbability: 0.5, random: () => 0.95 }); + ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 }); + ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 }); + const drops = ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 }); + expect(drops.filter((d) => d.item !== ItemType.CrystalJade).length).toBe(0); + }); + + it('non-Chi-Ren kill resets the consecutive counter', () => { + const ds = new DropSystem({ dianShuWanProbability: 1.0, random: () => 0 }); + ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 }); + ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 }); + ds.onEnemyKilled(EnemyType.QingRen, { x: 0, y: 0 }); + const drops = ds.onEnemyKilled(EnemyType.ChiRen, { x: 0, y: 0 }); + // Only 1 Chi Ren kill since reset — below threshold. + expect(drops.filter((d) => d.item === ItemType.DianWan || d.item === ItemType.ShuWan).length).toBe(0); + }); +}); + +describe('DropSystem — reset()', () => { + it('zeroes kill counters', () => { + const ds = new DropSystem(); + ds.onEnemyKilled(EnemyType.QingRen, { x: 0, y: 0 }); + ds.reset(); + expect(ds.kills).toBe(0); + }); +}); diff --git a/tests/logic/EnemyAI.test.ts b/tests/logic/EnemyAI.test.ts new file mode 100644 index 0000000..2339d80 --- /dev/null +++ b/tests/logic/EnemyAI.test.ts @@ -0,0 +1,116 @@ +import { ChiRenAI, EnemyManager, HeiRenAI, QingRenAI, YaoFangAI } from '@logic/EnemyAI'; +import { EnemyType, IEnemyConfig } from '@data/Interfaces'; + +function cfg(id: EnemyType, intervalSec: number, speed = 0): IEnemyConfig { + return { + id, + displayName: id, + size: { w: 16, h: 16 }, + moveSpeed: speed, + attackIntervalSec: intervalSec, + attacks: ['shuriken'], + hp: 1, + }; +} + +const idlePlayer = { x: 300, y: 16, isGrounded: true }; + +describe('QingRenAI (req 6.1)', () => { + it('throws a shuriken at the player when out of melee range', () => { + const ai = new QingRenAI(cfg(EnemyType.QingRen, 2.0), 0, 16); + const actions = ai.update({ dtSec: 2.0, nowMs: 0, player: idlePlayer }); + expect(actions[0].kind).toBe('fire_bullet'); + expect(actions[0].attackType).toBe('shuriken'); + expect(actions[0].velX).toBeGreaterThan(0); + }); + + it('melee swings when player is close', () => { + const ai = new QingRenAI(cfg(EnemyType.QingRen, 2.0), 310, 16); + const actions = ai.update({ dtSec: 2.0, nowMs: 0, player: idlePlayer }); + expect(actions[0].kind).toBe('melee_swing'); + }); + + it('respects attack interval — no burst within one tick', () => { + const ai = new QingRenAI(cfg(EnemyType.QingRen, 2.0), 0, 16); + ai.update({ dtSec: 2.0, nowMs: 0, player: idlePlayer }); + const actions = ai.update({ dtSec: 0.1, nowMs: 0, player: idlePlayer }); + expect(actions.length).toBe(0); + }); +}); + +describe('ChiRenAI (req 6.2-6.3)', () => { + it('moves horizontally toward the player at 120 px/s', () => { + const ai = new ChiRenAI(cfg(EnemyType.ChiRen, 1.5, 120), 0, 16); + ai.update({ dtSec: 1, nowMs: 0, player: idlePlayer }); + expect(ai.pos.x).toBeGreaterThan(0); + }); + + it('throws smoke bombs at the configured cadence', () => { + const ai = new ChiRenAI(cfg(EnemyType.ChiRen, 1.5, 120), 0, 16); + const actions = ai.update({ dtSec: 1.5, nowMs: 0, player: idlePlayer }); + expect(actions.some((a) => a.attackType === 'smoke_bomb')).toBe(true); + }); + + it('bumps upward when the player is idle within intercept range', () => { + const ai = new ChiRenAI(cfg(EnemyType.ChiRen, 1.5, 120), 250, 16); + const before = ai.pos.y; + ai.update({ dtSec: 0.016, nowMs: 0, player: idlePlayer }); + expect(ai.pos.y).toBeGreaterThan(before); + }); +}); + +describe('HeiRenAI (req 6.5)', () => { + it('drops exactly one magic flute on kill', () => { + const ai = new HeiRenAI(cfg(EnemyType.HeiRen, 2.5), 100, 16); + const firstDrop = ai.onKilled(); + const secondDrop = ai.onKilled(); + expect(firstDrop[0].itemId).toBe('mo_di'); + expect(secondDrop.length).toBe(0); + }); +}); + +describe('YaoFangAI (req 6.6)', () => { + it('launches straight-line fireballs at 3s cadence', () => { + const ai = new YaoFangAI(cfg(EnemyType.YaoFang, 3.0), 0, 16); + const a = ai.update({ dtSec: 3.0, nowMs: 0, player: idlePlayer }); + expect(a[0].attackType).toBe('fireball'); + }); +}); + +describe('EnemyManager culling (req 6.7)', () => { + it('skips update for enemies outside the camera cull rect', () => { + const mgr = new EnemyManager(); + const far = new YaoFangAI(cfg(EnemyType.YaoFang, 3.0), 5000, 16); + mgr.spawn(far); + const actions = mgr.update(3.0, 0, idlePlayer, { + leftX: 0, + rightX: 960, + topY: 540, + bottomY: 0, + }); + expect(actions.length).toBe(0); + }); + + it('updates enemies inside the cull rect', () => { + const mgr = new EnemyManager(); + const near = new YaoFangAI(cfg(EnemyType.YaoFang, 3.0), 200, 16); + mgr.spawn(near); + const actions = mgr.update(3.0, 0, idlePlayer, { + leftX: 0, + rightX: 960, + topY: 540, + bottomY: 0, + }); + expect(actions.length).toBe(1); + expect(actions[0].kind).toBe('fire_bullet'); + }); + + it('kill() converts Hei Ren to a magic-flute drop', () => { + const mgr = new EnemyManager(); + const hei = new HeiRenAI(cfg(EnemyType.HeiRen, 2.5), 50, 16); + mgr.spawn(hei); + const drops = mgr.kill(hei); + expect(drops[0].itemId).toBe('mo_di'); + expect(hei.alive).toBe(false); + }); +}); diff --git a/tests/logic/JumpController.test.ts b/tests/logic/JumpController.test.ts new file mode 100644 index 0000000..5e1de44 --- /dev/null +++ b/tests/logic/JumpController.test.ts @@ -0,0 +1,105 @@ +import { PlayerMotionModel } from '@logic/PlayerMotionModel'; +import { JumpController, PARABOLIC_HORIZONTAL_SPEED, heightToImpulse } from '@logic/JumpController'; +import { + JUMP_HEIGHT_STANDARD, + JUMP_HEIGHT_CHARGED, + JUMP_HEIGHT_YELLOW, + JUMP_PREPARE_DELAY_MS, + PlayerColorState, +} from '@common/Constants'; + +function newPair(color: PlayerColorState = PlayerColorState.Red) { + const motion = new PlayerMotionModel({ + aabb: { x: 0, y: 16, w: 16, h: 32 }, + platforms: [{ topY: 0, leftX: -500, rightX: 500 }], + initialColorState: color, + }); + motion.update(0.016); // settle on ground + const jump = new JumpController(motion); + return { motion, jump }; +} + +describe('JumpController — press / release lifecycle (req 2.2, 2.3, 2.4, 2.8)', () => { + it('refuses to press when airborne (req 2.4)', () => { + const { motion, jump } = newPair(); + motion.applyJumpImpulse(500); // lift off manually + const res = jump.pressJump(0); + expect(res.reason).toBe('airborne'); + expect(jump.isButtonEnabled()).toBe(false); + }); + + it('standard-press + quick release → standard jump height', () => { + const { jump } = newPair(); + jump.pressJump(0); + const res = jump.releaseJump(100, 'horizontal'); + expect(res.height).toBe(JUMP_HEIGHT_STANDARD); + expect(res.phase).toBe('crouching'); + }); + + it('holding ≥500ms produces the charged jump (req 2.3)', () => { + const { jump } = newPair(); + jump.pressJump(0); + const res = jump.releaseJump(600, 'horizontal'); + expect(res.height).toBe(JUMP_HEIGHT_CHARGED); + }); + + it('yellow color state uses 300px baseline jump (req 2.2)', () => { + const { jump } = newPair(PlayerColorState.Yellow); + jump.pressJump(0); + const res = jump.releaseJump(50, 'horizontal', PlayerColorState.Yellow); + expect(res.height).toBe(JUMP_HEIGHT_YELLOW); + }); +}); + +describe('JumpController — parabolic impulse (req 2.5)', () => { + it('parabolic_right imparts +PARABOLIC_HORIZONTAL_SPEED', () => { + const { jump } = newPair(); + jump.pressJump(0); + const res = jump.releaseJump(100, 'parabolic_right'); + expect(res.horizontalImpulse).toBe(PARABOLIC_HORIZONTAL_SPEED); + }); + + it('parabolic_left imparts −PARABOLIC_HORIZONTAL_SPEED', () => { + const { jump } = newPair(); + jump.pressJump(0); + const res = jump.releaseJump(100, 'parabolic_left'); + expect(res.horizontalImpulse).toBe(-PARABOLIC_HORIZONTAL_SPEED); + }); +}); + +describe('JumpController — crouch delay + launch + re-enable (req 2.8, 2.4)', () => { + it('does not apply impulse until after 150ms crouch delay', () => { + const { motion, jump } = newPair(); + jump.pressJump(0); + jump.releaseJump(100, 'horizontal'); + // Inside the crouch window — still grounded because impulse not applied. + jump.tick(200); + expect(motion.vy).toBe(0); + expect(motion.isGrounded).toBe(true); + jump.tick(100 + JUMP_PREPARE_DELAY_MS + 1); + expect(motion.vy).toBeGreaterThan(0); + expect(motion.isGrounded).toBe(false); + }); + + it('re-enables the jump button once the player lands again', () => { + const { motion, jump } = newPair(); + jump.pressJump(0); + jump.releaseJump(50, 'horizontal'); + jump.tick(50 + JUMP_PREPARE_DELAY_MS + 1); + // Let gravity bring the player back down. + for (let i = 0; i < 120; i++) motion.update(1 / 60); + jump.tick(1_000); + expect(motion.isGrounded).toBe(true); + expect(jump.isButtonEnabled()).toBe(true); + }); +}); + +describe('heightToImpulse — physics math', () => { + it('computes impulse such that peak equals the requested height', () => { + const g = 2500; + const h = 250; + const v0 = heightToImpulse(h, g); + // At apex v=0 ⇒ t = v0/g ⇒ peak = v0 * t - 0.5 * g * t^2 ⇒ v0^2 / (2g) + expect((v0 * v0) / (2 * g)).toBeCloseTo(h, 3); + }); +}); diff --git a/tests/logic/LevelFramework.test.ts b/tests/logic/LevelFramework.test.ts new file mode 100644 index 0000000..cc27737 --- /dev/null +++ b/tests/logic/LevelFramework.test.ts @@ -0,0 +1,130 @@ +import { CameraScroller, PARALLAX_LAYERS, PARALLAX_RATIOS, cameraFromLevel } from '@logic/CameraScroller'; +import { LevelMgr } from '@logic/LevelMgr'; +import { EnemyType, ILevelConfig } from '@data/Interfaces'; + +const HORIZONTAL_LEVEL: ILevelConfig = { + id: '1-1', + chapter: 1, + displayName: '初始森林', + sceneTheme: 'forest', + scrollDirection: 'horizontal', + timeLimitSec: 75, + objective: { kind: 'kill_count', enemy: EnemyType.YaoFang, count: 3 }, + levelLengthPx: 3840, + bgm: 'bgm_forest', + enemySpawns: [], +}; + +describe('CameraScroller — horizontal (req 8.1)', () => { + it('camera scrolls forward as player advances', () => { + const cam = cameraFromLevel(HORIZONTAL_LEVEL, 960, 540); + cam.followPlayer(480, 270); + expect(cam.offsetX).toBe(0); + cam.followPlayer(900, 270); + expect(cam.offsetX).toBe(420); + }); + + it('camera never rewinds on horizontal scroll (req 8.1)', () => { + const cam = cameraFromLevel(HORIZONTAL_LEVEL); + cam.followPlayer(1500, 270); + const forward = cam.offsetX; + cam.followPlayer(100, 270); + expect(cam.offsetX).toBe(forward); // did not rewind + }); + + it('camera stops at level end', () => { + const cam = cameraFromLevel(HORIZONTAL_LEVEL); + cam.followPlayer(10_000, 270); + expect(cam.offsetX).toBe(HORIZONTAL_LEVEL.levelLengthPx - 960); + }); +}); + +describe('CameraScroller — parallax layers (req 8.8 — 1:2:4:4)', () => { + it('exposes 4 layers with ratios 1,2,4,4', () => { + expect(PARALLAX_LAYERS).toEqual(['far', 'mid', 'near', 'fx']); + expect([...PARALLAX_RATIOS]).toEqual([1, 2, 4, 4]); + }); + + it('far/mid/near produce progressively smaller offsets', () => { + const cam = cameraFromLevel(HORIZONTAL_LEVEL); + cam.followPlayer(1200, 270); + const far = cam.offsetForLayer('far'); + const mid = cam.offsetForLayer('mid'); + const near = cam.offsetForLayer('near'); + expect(far.x).toBeGreaterThan(mid.x); + expect(mid.x).toBeGreaterThan(near.x); + }); +}); + +describe('CameraScroller — bi-directional + vertical', () => { + it('horizontal_bi rewinds when the player walks backward', () => { + const cam = new CameraScroller({ + direction: 'horizontal_bi', + lengthX: 4800, + viewportW: 960, + viewportH: 540, + }); + cam.followPlayer(2000, 0); + cam.followPlayer(400, 0); + expect(cam.offsetX).toBe(0); + }); + + it('vertical rising clamps at top', () => { + const cam = new CameraScroller({ + direction: 'vertical', + lengthX: 960, + lengthY: 3240, + viewportW: 960, + viewportH: 540, + }); + cam.followPlayer(480, 6000); + expect(cam.offsetY).toBe(3240 - 540); + }); +}); + +describe('LevelMgr — objective / timer / result', () => { + it('reports victory when kill objective is met', () => { + const lv = new LevelMgr(HORIZONTAL_LEVEL); + lv.onEnemyKilled(EnemyType.YaoFang); + lv.onEnemyKilled(EnemyType.YaoFang); + lv.onEnemyKilled(EnemyType.YaoFang); + expect(lv.tick(0.016)).toBe('victory'); + }); + + it('reports timeout when time-limit expires', () => { + const lv = new LevelMgr(HORIZONTAL_LEVEL); + expect(lv.tick(HORIZONTAL_LEVEL.timeLimitSec + 0.1)).toBe('timeout'); + }); + + it('player_dead is terminal and outranks victory', () => { + const lv = new LevelMgr(HORIZONTAL_LEVEL); + lv.onPlayerDied(); + expect(lv.tick(0.1)).toBe('player_dead'); + }); + + it('reach_top objective', () => { + const cfg: ILevelConfig = { ...HORIZONTAL_LEVEL, objective: { kind: 'reach_top' } }; + const lv = new LevelMgr(cfg); + lv.onReachedTop(); + expect(lv.tick(0.016)).toBe('victory'); + }); + + it('defeat_boss objective', () => { + const cfg: ILevelConfig = { + ...HORIZONTAL_LEVEL, + objective: { kind: 'defeat_boss', bossId: 'shuang_huan_fang' }, + }; + const lv = new LevelMgr(cfg); + lv.onBossKilled(); + expect(lv.tick(0.016)).toBe('victory'); + }); + + it('result() returns kills and remaining seconds', () => { + const lv = new LevelMgr(HORIZONTAL_LEVEL); + lv.onEnemyKilled(EnemyType.QingRen); + lv.tick(10); + const r = lv.result(); + expect(r.kills[EnemyType.QingRen]).toBe(1); + expect(r.remainingSec).toBeCloseTo(65, 1); + }); +}); diff --git a/tests/logic/PlayerMotionModel.test.ts b/tests/logic/PlayerMotionModel.test.ts new file mode 100644 index 0000000..111d553 --- /dev/null +++ b/tests/logic/PlayerMotionModel.test.ts @@ -0,0 +1,106 @@ +import { PlayerMotionModel, IPlatform, DEFAULT_GRAVITY } from '@logic/PlayerMotionModel'; +import { MOVE_SPEED, PlayerColorState } from '@common/Constants'; + +function makeGroundPlatform(): IPlatform { + return { topY: 0, leftX: -1000, rightX: 1000 }; +} + +function makeModel(color: PlayerColorState = PlayerColorState.Red) { + return new PlayerMotionModel({ + aabb: { x: 0, y: 16, w: 16, h: 32 }, // character 16x32 resting on y=0 ground + platforms: [makeGroundPlatform()], + initialColorState: color, + }); +} + +describe('PlayerMotionModel — horizontal movement (req 2.1, 5.1-5.2)', () => { + it('stands still on initialisation', () => { + const m = makeModel(); + m.update(0.016); + expect(m.vx).toBe(0); + expect(m.isGrounded).toBe(true); + }); + + it('moves at 100 px/s in red state', () => { + const m = makeModel(PlayerColorState.Red); + m.update(0.016); // settle + m.setHorizontalInput(1); + m.update(1); + expect(m.vx).toBe(MOVE_SPEED[PlayerColorState.Red]); + expect(m.aabb.x).toBeCloseTo(100, 1); + }); + + it('moves at 150 px/s in yellow state', () => { + const m = makeModel(PlayerColorState.Yellow); + m.update(0.016); + m.setHorizontalInput(-1); + m.update(1); + expect(m.vx).toBe(-MOVE_SPEED[PlayerColorState.Yellow]); + }); + + it('reflects speed immediately when setColorState is called mid-run', () => { + const m = makeModel(PlayerColorState.Red); + m.update(0.016); + m.setHorizontalInput(1); + m.setColorState(PlayerColorState.Yellow); + m.update(1); + expect(m.vx).toBe(150); + }); +}); + +describe('PlayerMotionModel — jump / gravity (req 2.4, 13.4)', () => { + it('applyJumpImpulse is rejected when in the air (req 2.4)', () => { + const m = makeModel(); + m.update(0.016); + expect(m.applyJumpImpulse(600)).toBe(true); // first jump succeeds + expect(m.isGrounded).toBe(false); + expect(m.applyJumpImpulse(600)).toBe(false); // second jump in air denied + }); + + it('gravity pulls the player back to the ground', () => { + const m = makeModel(); + m.update(0.016); + m.applyJumpImpulse(600); + // Simulate ~1 second of flight — gravity reclaims the player. + for (let i = 0; i < 120; i++) m.update(1 / 60); + expect(m.isGrounded).toBe(true); + expect(m.vy).toBe(0); + }); + + it('preserves mid-air vx (起跳定型 — req 13.4)', () => { + const m = makeModel(); + m.update(0.016); + // Build horizontal speed + jump. + m.setHorizontalInput(1); + m.update(0.016); + m.applyJumpImpulse(500); + const airVx = m.vx; + // Even if the player now tries to flip direction while airborne, vx stays put. + m.setHorizontalInput(-1); + m.update(0.05); + expect(m.vx).toBe(airVx); + }); + + it('applyHorizontalImpulse overrides vx (for parabolic jumps, req 2.5)', () => { + const m = makeModel(); + m.update(0.016); + m.applyJumpImpulse(600); + m.applyHorizontalImpulse(120); + m.update(0.016); + expect(m.vx).toBe(120); + }); +}); + +describe('PlayerMotionModel — platform switching', () => { + it('setPlatforms clears grounded and re-settles on next update', () => { + const m = makeModel(); + m.update(0.016); + expect(m.isGrounded).toBe(true); + m.setPlatforms([{ topY: -500, leftX: -10, rightX: 10 }]); + expect(m.isGrounded).toBe(false); + }); + + it('gravity constant matches the documented default', () => { + expect(DEFAULT_GRAVITY).toBe(2500); + }); +}); diff --git a/tests/logic/PlayerStateMachine.test.ts b/tests/logic/PlayerStateMachine.test.ts new file mode 100644 index 0000000..5492742 --- /dev/null +++ b/tests/logic/PlayerStateMachine.test.ts @@ -0,0 +1,119 @@ +import { PlayerStateMachine } from '@logic/PlayerStateMachine'; +import { PlayerColorState, PLAYER_IFRAME_SECONDS } from '@common/Constants'; + +describe('PlayerStateMachine — auto-upgrade (req 5.1-5.2)', () => { + it('Red → Green on first crystal pickup', () => { + const sm = new PlayerStateMachine(); + expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Green); + }); + + it('Green → Yellow on second crystal pickup', () => { + const sm = new PlayerStateMachine(); + sm.pickupCrystalJade(); + expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Yellow); + }); + + it('Yellow → Yellow on additional crystal pickups (no overflow)', () => { + const sm = new PlayerStateMachine(); + sm.pickupCrystalJade(); + sm.pickupCrystalJade(); + expect(sm.pickupCrystalJade()).toBe(PlayerColorState.Yellow); + }); + + it('Zeng Wan adds one life (req 7.5)', () => { + const sm = new PlayerStateMachine(1); + expect(sm.pickupZengWan()).toBe(2); + }); +}); + +describe('PlayerStateMachine — damage (req 5.3-5.6, 10.4-10.5)', () => { + it('downgrades from Yellow to Red on shuriken hit', () => { + const sm = new PlayerStateMachine(); + sm.pickupCrystalJade(); + sm.pickupCrystalJade(); + const out = sm.takeHit('shuriken'); + expect(out).toEqual({ kind: 'downgraded', from: PlayerColorState.Yellow, to: PlayerColorState.Red }); + expect(sm.color).toBe(PlayerColorState.Red); + }); + + it('downgrades from Green to Red on sword hit', () => { + const sm = new PlayerStateMachine(); + sm.pickupCrystalJade(); + const out = sm.takeHit('sword'); + expect(out.kind).toBe('downgraded'); + expect(sm.color).toBe(PlayerColorState.Red); + }); + + it('Red + shuriken → death, consumes one life', () => { + const sm = new PlayerStateMachine(2); + const out = sm.takeHit('shuriken'); + expect(out.kind).toBe('died'); + expect(sm.lives).toBe(1); + expect(sm.isDead).toBe(false); + }); + + it('fireball is always lethal regardless of color', () => { + const sm = new PlayerStateMachine(2); + sm.pickupCrystalJade(); + sm.pickupCrystalJade(); + const out = sm.takeHit('fireball'); + expect(out).toEqual({ kind: 'died', cause: 'fireball' }); + expect(sm.color).toBe(PlayerColorState.Red); + }); + + it('smoke bomb is always lethal', () => { + const sm = new PlayerStateMachine(); + sm.pickupCrystalJade(); + const out = sm.takeHit('smoke_bomb'); + expect(out.kind).toBe('died'); + }); + + it('zero lives → isDead=true', () => { + const sm = new PlayerStateMachine(1); + sm.takeHit('sword'); + expect(sm.lives).toBe(0); + expect(sm.isDead).toBe(true); + }); +}); + +describe('PlayerStateMachine — iframes & sword parry (req 3.7-3.8, 10.2-10.3)', () => { + it('i-frames start at PLAYER_IFRAME_SECONDS after a hit', () => { + const sm = new PlayerStateMachine(); + sm.pickupCrystalJade(); + sm.takeHit('shuriken'); + expect(sm.snapshot.iframeSec).toBe(PLAYER_IFRAME_SECONDS); + }); + + it('hits during i-frames are ignored', () => { + const sm = new PlayerStateMachine(); + sm.pickupCrystalJade(); + sm.takeHit('shuriken'); + const out = sm.takeHit('shuriken'); + expect(out).toEqual({ kind: 'no_effect', reason: 'iframe' }); + }); + + it('tick() drains i-frames so the player is vulnerable again', () => { + const sm = new PlayerStateMachine(); + sm.pickupCrystalJade(); + sm.takeHit('shuriken'); + sm.tick(PLAYER_IFRAME_SECONDS + 0.01); + expect(sm.snapshot.iframeSec).toBe(0); + }); + + it('sword parry nullifies shuriken/sword damage', () => { + const sm = new PlayerStateMachine(); + sm.setSwordActive(true); + const out = sm.takeHit('shuriken'); + expect(out).toEqual({ kind: 'no_effect', reason: 'parried' }); + expect(sm.color).toBe(PlayerColorState.Red); + }); + + it('sword parry does NOT nullify fireball / smoke_bomb (req 3.8, 10.4-10.5)', () => { + const sm = new PlayerStateMachine(); + sm.setSwordActive(true); + expect(sm.takeHit('fireball').kind).toBe('died'); + const sm2 = new PlayerStateMachine(); + sm2.setSwordActive(true); + expect(sm2.takeHit('smoke_bomb').kind).toBe('died'); + }); +}); diff --git a/tests/logic/TutorialScore.test.ts b/tests/logic/TutorialScore.test.ts new file mode 100644 index 0000000..e2a123c --- /dev/null +++ b/tests/logic/TutorialScore.test.ts @@ -0,0 +1,114 @@ +import { TutorialMgr } from '@logic/TutorialMgr'; +import { ScoreSystem, BASE_ENEMY_SCORE, COMBO_BONUS } from '@logic/ScoreSystem'; +import { WeaponType } from '@data/Interfaces'; +import { StorageMgr } from '@common/StorageMgr'; + +function mem() { + const m = new Map(); + return { + getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null), + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }; +} + +describe('TutorialMgr — built-in sequences for 1-1..1-3 (req 11.1-11.3)', () => { + it('maybeStart returns the first step of 1-1', () => { + const t = new TutorialMgr(new StorageMgr(mem())); + const step = t.maybeStart('1-1'); + expect(step).toBeDefined(); + expect(step!.id).toBe('attack'); + }); + + it('reportAction advances through the sequence', () => { + const t = new TutorialMgr(new StorageMgr(mem())); + t.maybeStart('1-1'); + expect(t.reportAction('fire_shuriken')).toMatchObject({ id: 'joystick' }); + expect(t.reportAction('move')).toMatchObject({ id: 'jump' }); + expect(t.reportAction('jump')).toBe('finished'); + }); + + it('completed tutorials are persisted and skipped on replay (req 11.4)', () => { + const storage = new StorageMgr(mem()); + const t = new TutorialMgr(storage); + t.maybeStart('1-1'); + t.reportAction('fire_shuriken'); + t.reportAction('move'); + t.reportAction('jump'); + expect(t.isCompleted('1-1')).toBe(true); + expect(t.maybeStart('1-1')).toBeNull(); + }); + + it('resetAll clears the completion set (req 11.5)', () => { + const t = new TutorialMgr(new StorageMgr(mem())); + t.maybeStart('1-2'); + t.reportAction('parabolic_jump'); + t.reportAction('attack_switch'); + t.reportAction('parry'); + t.reportAction('jump_attack'); + t.reportAction('pickup_crystal'); + t.resetAll(); + expect(t.maybeStart('1-2')).toBeDefined(); + }); + + it('reportAction with wrong action is a no-op', () => { + const t = new TutorialMgr(new StorageMgr(mem())); + t.maybeStart('1-1'); + expect(t.reportAction('wrong')).toBe('no_op'); + expect(t.currentStep()!.id).toBe('attack'); + }); +}); + +describe('ScoreSystem — scoring table (req 12.1-12.6)', () => { + it('sword kill is ×2, shuriken kill is ×1', () => { + const s = new ScoreSystem(); + s.recordEnemyKill(WeaponType.NinjaSword); + s.recordEnemyKill(WeaponType.Shuriken); + expect(s.snapshot().baseScore).toBe(BASE_ENEMY_SCORE * 3); + }); + + it('parry kill is ×3 base', () => { + const s = new ScoreSystem(); + s.recordParryKill(); + expect(s.snapshot().baseScore).toBe(BASE_ENEMY_SCORE * 3); + }); + + it('5 blade contacts award a +1500 combo bonus (req 12.4)', () => { + const s = new ScoreSystem(); + for (let i = 0; i < 5; i++) s.recordBladeContact(); + const snap = s.snapshot(); + expect(snap.comboBonus).toBe(COMBO_BONUS); + expect(snap.comboCount).toBe(1); + expect(snap.consecutiveBladeHits).toBe(0); + }); + + it('breakBladeChain resets the streak', () => { + const s = new ScoreSystem(); + s.recordBladeContact(); + s.recordBladeContact(); + s.breakBladeChain(); + s.recordBladeContact(); + expect(s.snapshot().consecutiveBladeHits).toBe(1); + }); + + it('flawless run triples total score; taking damage removes the bonus (req 12.5)', () => { + const s = new ScoreSystem(); + s.recordEnemyKill(WeaponType.Shuriken); + const flawlessSnap = s.snapshot(); + expect(flawlessSnap.flawlessMultiplier).toBe(3); + s.markTaken(); + const damagedSnap = s.snapshot(); + expect(damagedSnap.flawlessMultiplier).toBe(1); + }); + + it('remaining time bonus adds 10 pts / sec (req 12.6)', () => { + const s = new ScoreSystem(); + s.recordEnemyKill(WeaponType.Shuriken); + s.setRemainingTimeBonus(30); + expect(s.snapshot().timeBonus).toBe(300); + }); +}); diff --git a/tests/ui/InputModel.test.ts b/tests/ui/InputModel.test.ts new file mode 100644 index 0000000..43439d9 --- /dev/null +++ b/tests/ui/InputModel.test.ts @@ -0,0 +1,147 @@ +import { + ControlId, + DEFAULT_LAYOUT, + MultiTouchRouter, + applySafeArea, + classifyDirection, + hitTest, + isInsideRect, + joystickDirection, + ZERO_DIRECTION, +} from '@ui/InputModel'; + +describe('InputModel — layout geometry', () => { + it('default landscape layout places joystick on left, attacks on right', () => { + expect(DEFAULT_LAYOUT.joystick.cx).toBeLessThan(480); + expect(DEFAULT_LAYOUT.shuriken.cx).toBeGreaterThan(480); + expect(DEFAULT_LAYOUT.ninjaSword.cx).toBeGreaterThan(DEFAULT_LAYOUT.shuriken.cx); + }); + + it('isInsideRect is correct for corners and near-misses', () => { + const r = { cx: 100, cy: 100, w: 40, h: 40 }; + expect(isInsideRect(r, 100, 100)).toBe(true); + expect(isInsideRect(r, 120, 120)).toBe(true); + expect(isInsideRect(r, 121, 100)).toBe(false); + expect(isInsideRect(r, 100, 79)).toBe(false); + }); +}); + +describe('InputModel — hitTest priority', () => { + it('routes a finger pressing both an attack and the joystick to the attack', () => { + // Arrange: stretch the joystick rect so it overlaps the shuriken button. + const layout = { + ...DEFAULT_LAYOUT, + joystick: { cx: 120, cy: 100, w: 900, h: 200 }, + }; + const id = hitTest(layout, layout.shuriken.cx, layout.shuriken.cy); + expect(id).toBe(ControlId.Shuriken); + }); + + it('returns null when the touch misses every control', () => { + expect(hitTest(DEFAULT_LAYOUT, 480, 400)).toBeNull(); + }); +}); + +describe('InputModel — joystick dead zone (req 1.5)', () => { + it('returns ZERO_DIRECTION inside the 10px dead-zone', () => { + const dir = joystickDirection(DEFAULT_LAYOUT, DEFAULT_LAYOUT.joystick.cx + 4, DEFAULT_LAYOUT.joystick.cy - 3); + expect(dir).toBe(ZERO_DIRECTION); + }); + + it('returns a normalised vector with magnitude > deadzone outside it', () => { + const dir = joystickDirection( + DEFAULT_LAYOUT, + DEFAULT_LAYOUT.joystick.cx + 60, + DEFAULT_LAYOUT.joystick.cy + 0 + ); + expect(dir.magnitude).toBeCloseTo(60); + expect(dir.x).toBeCloseTo(1); + expect(dir.y).toBeCloseTo(0); + }); +}); + +describe('InputModel — parabolic angle classification (req 2.5, 20.3)', () => { + it.each([ + [45, 'parabolic_right'], + [50, 'parabolic_right'], + [40, 'parabolic_right'], + [60, 'parabolic_right'], + [135, 'parabolic_left'], + [140, 'parabolic_left'], + [120, 'parabolic_left'], + [0, 'horizontal'], + [180, 'horizontal'], + [90, 'other'], + ] as Array<[number, string]>)('degree %p → %p', (deg, klass) => { + const rad = (deg * Math.PI) / 180; + const dir = { x: Math.cos(rad), y: Math.sin(rad), magnitude: 1 }; + expect(classifyDirection(dir)).toBe(klass); + }); + + it('hits ≥95% recognition rate when sampling evenly around 45° ± 15°', () => { + let hits = 0; + const samples = 200; + for (let i = 0; i < samples; i++) { + const deg = 30 + (i / samples) * 30; // 30°..60° + const rad = (deg * Math.PI) / 180; + const k = classifyDirection({ x: Math.cos(rad), y: Math.sin(rad), magnitude: 1 }); + if (k === 'parabolic_right') hits++; + } + expect(hits / samples).toBeGreaterThanOrEqual(0.95); + }); +}); + +describe('InputModel — applySafeArea (req 1.7, 18.6)', () => { + it('slides left-group rightwards and right-group leftwards by the insets', () => { + const shifted = applySafeArea(DEFAULT_LAYOUT, { left: 40, right: 60, top: 0, bottom: 0 }); + expect(shifted.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx + 40); + expect(shifted.jump.cx).toBe(DEFAULT_LAYOUT.jump.cx + 40); + expect(shifted.shuriken.cx).toBe(DEFAULT_LAYOUT.shuriken.cx - 60); + expect(shifted.ninjaSword.cx).toBe(DEFAULT_LAYOUT.ninjaSword.cx - 60); + }); + + it('keeps every control inside the visible area after shifting', () => { + const shifted = applySafeArea(DEFAULT_LAYOUT, { left: 20, right: 20, top: 20, bottom: 20 }); + const all = [shifted.joystick, shifted.jump, shifted.shuriken, shifted.ninjaSword]; + for (const r of all) { + expect(r.cx + r.w / 2).toBeLessThanOrEqual(960); + expect(r.cx - r.w / 2).toBeGreaterThanOrEqual(0); + expect(r.cy + r.h / 2).toBeLessThanOrEqual(540); + expect(r.cy - r.h / 2).toBeGreaterThanOrEqual(0); + } + }); +}); + +describe('MultiTouchRouter — req 1.8 (≥3 simultaneous touches)', () => { + it('routes three fingers to joystick + jump + shuriken independently', () => { + const router = new MultiTouchRouter(DEFAULT_LAYOUT); + const j = router.begin(0, DEFAULT_LAYOUT.joystick.cx, DEFAULT_LAYOUT.joystick.cy, 0); + const p = router.begin(1, DEFAULT_LAYOUT.jump.cx, DEFAULT_LAYOUT.jump.cy, 10); + const s = router.begin(2, DEFAULT_LAYOUT.shuriken.cx, DEFAULT_LAYOUT.shuriken.cy, 20); + expect(j).toBe(ControlId.Joystick); + expect(p).toBe(ControlId.Jump); + expect(s).toBe(ControlId.Shuriken); + expect(router.activeTouchCount).toBe(3); + expect(router.isPressed(ControlId.Jump)).toBe(true); + }); + + it('end() returns the previously-bound control and removes the slot', () => { + const router = new MultiTouchRouter(DEFAULT_LAYOUT); + router.begin(5, DEFAULT_LAYOUT.ninjaSword.cx, DEFAULT_LAYOUT.ninjaSword.cy, 0); + expect(router.end(5)).toBe(ControlId.NinjaSword); + expect(router.isPressed(ControlId.NinjaSword)).toBe(false); + }); + + it('falls through (returns null) when the touch lands outside all controls (req 1.3)', () => { + const router = new MultiTouchRouter(DEFAULT_LAYOUT); + expect(router.begin(7, 480, 400, 0)).toBeNull(); + }); + + it('earliestPressTs returns the oldest timestamp among the given controls', () => { + const router = new MultiTouchRouter(DEFAULT_LAYOUT); + router.begin(0, DEFAULT_LAYOUT.jump.cx, DEFAULT_LAYOUT.jump.cy, 100); + router.begin(1, DEFAULT_LAYOUT.shuriken.cx, DEFAULT_LAYOUT.shuriken.cy, 80); + expect(router.earliestPressTs([ControlId.Jump, ControlId.Shuriken])).toBe(80); + expect(router.earliestPressTs([ControlId.NinjaSword])).toBeUndefined(); + }); +}); diff --git a/tests/ui/LayoutCustomizer.test.ts b/tests/ui/LayoutCustomizer.test.ts new file mode 100644 index 0000000..72a3ced --- /dev/null +++ b/tests/ui/LayoutCustomizer.test.ts @@ -0,0 +1,129 @@ +import { + DEFAULT_LAYOUT_DELTA, + LAYOUT_DELTA_BOUNDS, + LayoutCustomizer, + applyLayoutDelta, + sanitiseLayoutDelta, +} from '@ui/LayoutCustomizer'; +import { DEFAULT_LAYOUT } from '@ui/InputModel'; +import { StorageMgr } from '@common/StorageMgr'; +import { STORAGE_KEY } from '@common/Constants'; + +function mem() { + const m = new Map(); + return { + getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null), + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }; +} + +describe('sanitiseLayoutDelta', () => { + it('returns a defensive copy of the default when given null', () => { + const d = sanitiseLayoutDelta(null); + expect(d).toEqual(DEFAULT_LAYOUT_DELTA); + d.opacity = 0.1; + expect(DEFAULT_LAYOUT_DELTA.opacity).toBe(0.7); // ensure we did not mutate + }); + + it('clamps offset beyond the allowed range', () => { + const d = sanitiseLayoutDelta({ joystickOffset: { dx: 9999, dy: -9999 } }); + expect(d.joystickOffset.dx).toBe(LAYOUT_DELTA_BOUNDS.offsetPxMax); + expect(d.joystickOffset.dy).toBe(-LAYOUT_DELTA_BOUNDS.offsetPxMax); + }); + + it('clamps size scale and opacity to allowed ranges', () => { + const d = sanitiseLayoutDelta({ buttonSizeScale: 5, opacity: 2 }); + expect(d.buttonSizeScale).toBe(LAYOUT_DELTA_BOUNDS.sizeScaleMax); + expect(d.opacity).toBe(LAYOUT_DELTA_BOUNDS.opacityMax); + }); + + it('replaces NaN-like inputs with safe midpoints', () => { + const d = sanitiseLayoutDelta({ buttonSizeScale: NaN }); + expect(d.buttonSizeScale).toBeGreaterThanOrEqual(LAYOUT_DELTA_BOUNDS.sizeScaleMin); + expect(d.buttonSizeScale).toBeLessThanOrEqual(LAYOUT_DELTA_BOUNDS.sizeScaleMax); + }); +}); + +describe('applyLayoutDelta', () => { + it('produces a layout identical to baseline when delta is default', () => { + const result = applyLayoutDelta(DEFAULT_LAYOUT, DEFAULT_LAYOUT_DELTA); + expect(result.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx); + expect(result.opacity).toBe(DEFAULT_LAYOUT_DELTA.opacity); + }); + + it('shifts centres and scales widths', () => { + const delta = sanitiseLayoutDelta({ + joystickOffset: { dx: 30, dy: 10 }, + buttonSizeScale: 1.2, + opacity: 0.9, + }); + const r = applyLayoutDelta(DEFAULT_LAYOUT, delta); + expect(r.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx + 30); + expect(r.joystick.cy).toBe(DEFAULT_LAYOUT.joystick.cy + 10); + expect(r.shuriken.w).toBeCloseTo(DEFAULT_LAYOUT.shuriken.w * 1.2); + expect(r.opacity).toBe(0.9); + }); +}); + +describe('LayoutCustomizer — persistence', () => { + it('returns the default layout when nothing is stored (req 17.6)', () => { + const cust = new LayoutCustomizer(DEFAULT_LAYOUT, new StorageMgr(mem())); + const { delta, layout } = cust.loadLayout(); + expect(delta).toEqual(DEFAULT_LAYOUT_DELTA); + expect(layout.joystick.cx).toBe(DEFAULT_LAYOUT.joystick.cx); + }); + + it('round-trips a custom delta through saveDelta() / loadLayout()', () => { + const storage = new StorageMgr(mem()); + const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage); + cust.saveDelta({ + joystickOffset: { dx: 20, dy: -15 }, + jumpOffset: { dx: 0, dy: 0 }, + shurikenOffset: { dx: -10, dy: 5 }, + ninjaSwordOffset: { dx: 0, dy: 0 }, + buttonSizeScale: 1.1, + opacity: 0.85, + }); + const { delta } = cust.loadLayout(); + expect(delta.joystickOffset.dx).toBe(20); + expect(delta.shurikenOffset.dx).toBe(-10); + expect(delta.opacity).toBe(0.85); + }); + + it('reset() clears the stored layout', () => { + const storage = new StorageMgr(mem()); + const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage); + cust.saveDelta({ ...DEFAULT_LAYOUT_DELTA, opacity: 1.0 }); + cust.reset(); + const { delta } = cust.loadLayout(); + expect(delta.opacity).toBe(DEFAULT_LAYOUT_DELTA.opacity); + }); + + it('falls back to defaults when storage returns corrupted JSON (req 17.6)', () => { + const driver = { + getItem: () => 'not valid json', + setItem: () => {}, + removeItem: () => {}, + }; + const storage = new StorageMgr(driver); + const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage); + const { delta } = cust.loadLayout(); + expect(delta).toEqual(DEFAULT_LAYOUT_DELTA); + }); + + it('uses the kl_control_layout storage key (req 17.2)', () => { + const driver = mem(); + const setSpy = jest.fn(driver.setItem); + driver.setItem = setSpy; + const storage = new StorageMgr(driver); + const cust = new LayoutCustomizer(DEFAULT_LAYOUT, storage); + cust.saveDelta(DEFAULT_LAYOUT_DELTA); + expect(setSpy).toHaveBeenCalled(); + expect(setSpy.mock.calls[0][0]).toBe(STORAGE_KEY.ControlLayout); + }); +}); diff --git a/tests/ui/StorySceneCtrl.test.ts b/tests/ui/StorySceneCtrl.test.ts new file mode 100644 index 0000000..100105c --- /dev/null +++ b/tests/ui/StorySceneCtrl.test.ts @@ -0,0 +1,116 @@ +import { StorySceneCtrl, BASE_TYPING_CPS } from '@ui/StorySceneCtrl'; +import { StorageMgr } from '@common/StorageMgr'; +import { IStorySceneConfig } from '@data/Interfaces'; +import { STORAGE_KEY } from '@common/Constants'; + +const SCENE: IStorySceneConfig = { +id: 'chapter_1_intro', + bgm: 'bgm_story', + maxDurationSec: 30, + pages: [ + { index: 1, illustration: 'p1', text: 'A' }, + { index: 2, illustration: 'p2', text: 'BCDE' }, + { index: 3, illustration: 'p3', text: 'FGH' }, + ], +}; + +function mem() { + const m = new Map(); + return { + getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null), + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }; +} + +describe('StorySceneCtrl — first-time gate (req 19.5)', () => { + it('start() reports "already_seen" when storage flag is set', () => { + const storage = new StorageMgr(mem()); + storage.set(STORAGE_KEY.StoryIntroSeen, true); + const ctrl = new StorySceneCtrl(SCENE, storage); + expect(ctrl.start()).toBe('already_seen'); + }); + + it('start() reports "playing" on first run', () => { + const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem())); + expect(ctrl.start()).toBe('playing'); + expect(ctrl.status).toBe('typing'); + }); +}); + +describe('StorySceneCtrl — typewriter (req 19.2-19.3)', () => { + it('reveals characters at BASE_TYPING_CPS', () => { + const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem())); + ctrl.start(); + // Advance enough time to type "A". + ctrl.tick(1 / BASE_TYPING_CPS + 0.001); + expect(ctrl.visibleText).toBe('A'); + // Page "A" complete → waiting_next. + expect(ctrl.status).toBe('waiting_next'); + }); + + it('tap during typing fully reveals the current page', () => { + const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem())); + ctrl.start(); + ctrl.onTap(); // accelerate page 1 ("A") + expect(ctrl.visibleText).toBe('A'); + expect(ctrl.status).toBe('waiting_next'); + // Next tap → advance to page 2. + ctrl.onTap(); + expect(ctrl.currentPageNumber).toBe(2); + expect(ctrl.status).toBe('typing'); + }); +}); + +describe('StorySceneCtrl — skip (req 19.4)', () => { + it('onSkip immediately finishes and marks seen', () => { + const storage = new StorageMgr(mem()); + const onFinished = jest.fn(); + const ctrl = new StorySceneCtrl(SCENE, storage, { onFinished }); + ctrl.start(); + ctrl.onSkip(); + expect(ctrl.status).toBe('finished'); + expect(onFinished).toHaveBeenCalledWith(true); + expect(storage.get(STORAGE_KEY.StoryIntroSeen, false)).toBe(true); + }); + + it('re-skipping after finish is a no-op', () => { + const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem())); + ctrl.start(); + ctrl.onSkip(); + expect(() => ctrl.onSkip()).not.toThrow(); + }); +}); + +describe('StorySceneCtrl — reset() (req 19.6)', () => { + it('clears the "seen" flag', () => { + const storage = new StorageMgr(mem()); + const ctrl = new StorySceneCtrl(SCENE, storage); + ctrl.start(); + ctrl.onSkip(); + expect(ctrl.hasBeenSeen()).toBe(true); + ctrl.reset(); + expect(ctrl.hasBeenSeen()).toBe(false); + }); +}); + +describe('StorySceneCtrl — natural finish (all pages)', () => { + it('calls onFinished(false) after advancing past the last page', () => { + const onFinished = jest.fn(); + const ctrl = new StorySceneCtrl(SCENE, new StorageMgr(mem()), { onFinished }); + ctrl.start(); + // Full page 1. + ctrl.onTap(); + ctrl.onTap(); // → page 2 + ctrl.onTap(); // reveal page 2 + ctrl.onTap(); // → page 3 + ctrl.onTap(); // reveal page 3 + ctrl.onTap(); // → finish + expect(onFinished).toHaveBeenCalledWith(false); + expect(ctrl.status).toBe('finished'); + }); +}); diff --git a/tests/ui/UIFlowMgr.test.ts b/tests/ui/UIFlowMgr.test.ts new file mode 100644 index 0000000..d07f206 --- /dev/null +++ b/tests/ui/UIFlowMgr.test.ts @@ -0,0 +1,61 @@ +import { UIFlowMgr, ISceneEnter } from '@ui/UIFlowMgr'; +import { StorageMgr } from '@common/StorageMgr'; +import { STORAGE_KEY } from '@common/Constants'; + +function mem() { + const m = new Map(); + return { + getItem: (k: string) => (m.has(k) ? (m.get(k) as string) : null), + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }; +} + +describe('UIFlowMgr — boot path (req 19.1, 19.5)', () => { + it('routes first-time boot into story_intro', () => { + const events: ISceneEnter[] = []; + const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) }); + flow.onBoot(); + expect(events[0].scene).toBe('story_intro'); + }); + + it('routes repeat boot into main_menu', () => { + const storage = new StorageMgr(mem()); + storage.set(STORAGE_KEY.StoryIntroSeen, true); + const events: ISceneEnter[] = []; + const flow = new UIFlowMgr(storage, { onSceneEnter: (e) => events.push(e) }); + flow.onBoot(); + expect(events[0].scene).toBe('main_menu'); + }); +}); + +describe('UIFlowMgr — level/settlement/dead transitions', () => { + it('onPickLevel → gameplay with payload', () => { + const events: ISceneEnter[] = []; + const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) }); + flow.onPickLevel('1-3'); + expect(events[0]).toEqual({ scene: 'gameplay', payload: { levelId: '1-3' } }); + }); + + it('onPlayerDied pushes a settlement with dead:true', () => { + const events: ISceneEnter[] = []; + const flow = new UIFlowMgr(new StorageMgr(mem()), { onSceneEnter: (e) => events.push(e) }); + flow.onPlayerDied('1-2'); + expect(events[0].scene).toBe('settlement'); + expect(events[0].payload).toEqual({ levelId: '1-2', dead: true }); + }); +}); + +describe('UIFlowMgr — difficulty guardrail (req 13.1)', () => { + it('availableSettingsEntries does NOT include difficulty', () => { + const flow = new UIFlowMgr(new StorageMgr(mem())); + const entries = flow.availableSettingsEntries(); + expect(entries).not.toContain('difficulty'); + expect(entries).toContain('audio_volume'); + expect(entries).toContain('replay_story_intro'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..36bbd37 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,49 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "node", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "resolveJsonModule": true, + "lib": ["ES2020", "DOM"], + "types": ["node", "jest"], + "baseUrl": "./", + "paths": { + "cc": ["tests/__mocks__/cc.ts"], + "@/*": ["assets/scripts/*"], + "@common/*": ["assets/scripts/common/*"], + "@data/*": ["assets/scripts/data/*"], + "@logic/*": ["assets/scripts/logic/*"], + "@ui/*": ["assets/scripts/ui/*"] + } + }, + "include": [ + "assets/scripts/**/*.ts", + "tests/**/*.ts" + ], + "exclude": [ + "node_modules", + "library", + "temp", + "build", + "settings", + "local" + ] +}